mirror of https://github.com/vernonkeenan/lib
feat: add native service principal contract
parent
edd36034e6
commit
5ad4f6103e
|
|
@ -44,13 +44,13 @@ pins the same module, a `lib` bump is a constellation-wide rebuild (KV-C6a).
|
||||||
| Path | What it is |
|
| Path | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app/root.go` | `InitConfig(systemName, level)` — loads `/etc/vernonkeenan/<systemName>.yaml` via viper, one-time init guard |
|
| `app/root.go` | `InitConfig(systemName, level)` — loads `/etc/vernonkeenan/<systemName>.yaml` via viper, one-time init guard |
|
||||||
| `app/config.go` | `Configuration` struct + `Get*`/`Is*` accessors (DBMS, DSN, log level, metrics, service accounts, workers, email, chunk/cache sizing) |
|
| `app/config.go` | `Configuration` struct + `Get*`/`Is*` accessors (DBMS, DSN, authentication mode, log level, metrics, service accounts, workers, email, chunk/cache sizing) |
|
||||||
| `app/mysql.go` | `InitDB()` — opens `MyDB *sql.DB` against the configured MySQL DSN |
|
| `app/mysql.go` | `InitDB()` — opens `MyDB *sql.DB` against the configured MySQL DSN |
|
||||||
| `app/force.go` | `InitForce(serviceAccountName)` — opens a Salesforce `go-force` API connection from a configured service account |
|
| `app/force.go` | `InitForce(serviceAccountName)` — opens a Salesforce `go-force` API connection from a configured service account |
|
||||||
| `app/auth.go` | `CheckAPIUser(token)` — validates an API key against `auth`'s generated client |
|
| `app/auth.go` | `CheckAPIUser(token)` — validates a credential against `auth`; errors never log the raw value |
|
||||||
| `app/prometheus.go` | `SetupPrometheusHandler` — CORS + request-count/duration middleware |
|
| `app/prometheus.go` | `SetupPrometheusHandler` — CORS + request-count/duration middleware |
|
||||||
| `app/service-helpers.go` | Pointer helpers (`String`/`Bool`/`Int64`/…), `GetFieldsAndValues` reflection helper (unit-tested) |
|
| `app/service-helpers.go` | Pointer helpers (`String`/`Bool`/`Int64`/…), `GetFieldsAndValues` reflection helper (unit-tested) |
|
||||||
| `app/user.go`, `user-helpers.go`, `tenantuser.go`, `userrole.go`, `address.go` | First-class domain structs + swagger marshal/unmarshal helpers shared by consumers |
|
| `app/user.go`, `user-helpers.go`, `tenantuser.go`, `userrole.go`, `address.go` | Compatibility principal/domain structs. ADR-KV-024 adds `PrincipalKey`, `PrincipalType`, exact `Scopes`, `HasScope`, and `IsServicePrincipal`; no native response carries its bearer secret. |
|
||||||
| `app/logger/logger.go` | Thin `zap.SugaredLogger` wrapper (`New(level)`, level constants) |
|
| `app/logger/logger.go` | Thin `zap.SugaredLogger` wrapper (`New(level)`, level constants) |
|
||||||
| `api/{auth,crm,members,plex,research,sfgate,stash}/` | go-swagger–generated typed clients + models, one directory per sibling service, regenerated from that service's own spec |
|
| `api/{auth,crm,members,plex,research,sfgate,stash}/` | go-swagger–generated typed clients + models, one directory per sibling service, regenerated from that service's own spec |
|
||||||
| `swagger/*.yaml`, `swagger/defs/`, `swagger/external/` | Copies of each sibling service's spec (the KV-C6b spec-copy problem — no sync mechanism besides `make regen`), shared component defs, and host/path-rewritten copies for external doc publishing; also carries legacy vendored Kazoo/Clerk specs from `lib`'s pre-mesh history |
|
| `swagger/*.yaml`, `swagger/defs/`, `swagger/external/` | Copies of each sibling service's spec (the KV-C6b spec-copy problem — no sync mechanism besides `make regen`), shared component defs, and host/path-rewritten copies for external doc publishing; also carries legacy vendored Kazoo/Clerk specs from `lib`'s pre-mesh history |
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,12 @@ type User struct {
|
||||||
// Portal Role Level
|
// Portal Role Level
|
||||||
PortalRole string `json:"PortalRole,omitempty"`
|
PortalRole string `json:"PortalRole,omitempty"`
|
||||||
|
|
||||||
|
// Stable machine principal key; empty for legacy human principals
|
||||||
|
PrincipalKey string `json:"PrincipalKey,omitempty"`
|
||||||
|
|
||||||
|
// Principal type, currently service or legacy_user
|
||||||
|
PrincipalType string `json:"PrincipalType,omitempty"`
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
ProfileID string `json:"ProfileID,omitempty"`
|
ProfileID string `json:"ProfileID,omitempty"`
|
||||||
|
|
||||||
|
|
@ -157,6 +163,9 @@ type User struct {
|
||||||
// Admin Info Emails
|
// Admin Info Emails
|
||||||
ReceivesAdminInfoEmails bool `json:"ReceivesAdminInfoEmails,omitempty"`
|
ReceivesAdminInfoEmails bool `json:"ReceivesAdminInfoEmails,omitempty"`
|
||||||
|
|
||||||
|
// Explicit mesh authorization scopes; never contains credentials
|
||||||
|
Scopes []string `json:"Scopes"`
|
||||||
|
|
||||||
// Email Sender Address
|
// Email Sender Address
|
||||||
SenderEmail string `json:"SenderEmail,omitempty"`
|
SenderEmail string `json:"SenderEmail,omitempty"`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
//
|
//
|
||||||
// When called, ChechAPIUser assumes there is an "auth" service account in the
|
// When called, ChechAPIUser assumes there is an "auth" service account in the
|
||||||
// app configuration file.
|
// app configuration file.
|
||||||
//
|
|
||||||
func CheckAPIUser(token *string) (*User, error) {
|
func CheckAPIUser(token *string) (*User, error) {
|
||||||
sugar.Debug("app.CheckAPIUser: 📥")
|
sugar.Debug("app.CheckAPIUser: 📥")
|
||||||
if authUser == nil {
|
if authUser == nil {
|
||||||
|
|
@ -27,7 +26,7 @@ func CheckAPIUser(token *string) (*User, error) {
|
||||||
params.Apikey = token
|
params.Apikey = token
|
||||||
response, err := authClient.User.GetUsers(params, authUser.Auth)
|
response, err := authClient.User.GetUsers(params, authUser.Auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sugar.Warnf("app.CheckAPIUser: ❗ Access attempt with invalid API key: %s", *token)
|
sugar.Warn("app.CheckAPIUser: ❗ access attempt with invalid API key")
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var obj *auth_models.User
|
var obj *auth_models.User
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ type ServiceAccount struct {
|
||||||
// Configuration defines the config struct
|
// Configuration defines the config struct
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
AppName string `mapstructure:"app_name,omitempty"`
|
AppName string `mapstructure:"app_name,omitempty"`
|
||||||
|
AuthMethod string `mapstructure:"auth_method,omitempty"`
|
||||||
BackendID string `mapstructure:"backend_id,omitempty"`
|
BackendID string `mapstructure:"backend_id,omitempty"`
|
||||||
BuildEnv string `mapstructure:"build_env,omitempty"`
|
BuildEnv string `mapstructure:"build_env,omitempty"`
|
||||||
CacheSizes map[string]CacheSize `mapstructure:"cache_sizes,omitempty"`
|
CacheSizes map[string]CacheSize `mapstructure:"cache_sizes,omitempty"`
|
||||||
|
|
@ -98,6 +99,11 @@ type Configuration struct {
|
||||||
Workers map[string]Worker `mapstructure:"workers,omitempty"`
|
Workers map[string]Worker `mapstructure:"workers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAuthMethod returns the configured authentication-authority mode.
|
||||||
|
func GetAuthMethod() string {
|
||||||
|
return config.AuthMethod
|
||||||
|
}
|
||||||
|
|
||||||
// GetCacheSize returns the named cache size
|
// GetCacheSize returns the named cache size
|
||||||
func GetCacheSize(name string) int64 {
|
func GetCacheSize(name string) int64 {
|
||||||
obj, ok := config.CacheSizes[name]
|
obj, ok := config.CacheSizes[name]
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,15 @@ func MarshalAuthUserToSwagger(obj *auth_models.User) *User {
|
||||||
OutOfOfficeMessage: obj.OutOfOfficeMessage,
|
OutOfOfficeMessage: obj.OutOfOfficeMessage,
|
||||||
Phone: obj.Phone,
|
Phone: obj.Phone,
|
||||||
PortalRole: obj.PortalRole,
|
PortalRole: obj.PortalRole,
|
||||||
|
PrincipalKey: obj.PrincipalKey,
|
||||||
|
PrincipalType: obj.PrincipalType,
|
||||||
ProfileID: obj.ProfileID,
|
ProfileID: obj.ProfileID,
|
||||||
ReceivesAdminEmails: obj.ReceivesAdminEmails,
|
ReceivesAdminEmails: obj.ReceivesAdminEmails,
|
||||||
ReceivesAdminInfoEmails: obj.ReceivesAdminInfoEmails,
|
ReceivesAdminInfoEmails: obj.ReceivesAdminInfoEmails,
|
||||||
SenderEmail: obj.SenderEmail,
|
SenderEmail: obj.SenderEmail,
|
||||||
SenderName: obj.SenderName,
|
SenderName: obj.SenderName,
|
||||||
Signature: obj.Signature,
|
Signature: obj.Signature,
|
||||||
|
Scopes: append([]string(nil), obj.Scopes...),
|
||||||
SmallPhotoURL: obj.SmallPhotoURL,
|
SmallPhotoURL: obj.SmallPhotoURL,
|
||||||
StartOfDay: obj.StartOfDay,
|
StartOfDay: obj.StartOfDay,
|
||||||
ExternalAccount: obj.ExternalAccount,
|
ExternalAccount: obj.ExternalAccount,
|
||||||
|
|
|
||||||
22
app/user.go
22
app/user.go
|
|
@ -57,6 +57,8 @@ type User struct {
|
||||||
OutOfOfficeMessage string
|
OutOfOfficeMessage string
|
||||||
Phone string
|
Phone string
|
||||||
PortalRole string
|
PortalRole string
|
||||||
|
PrincipalKey string
|
||||||
|
PrincipalType string
|
||||||
ProfileID string
|
ProfileID string
|
||||||
ReceivesAdminEmails bool
|
ReceivesAdminEmails bool
|
||||||
ReceivesAdminInfoEmails bool
|
ReceivesAdminInfoEmails bool
|
||||||
|
|
@ -64,6 +66,7 @@ type User struct {
|
||||||
SenderEmail string
|
SenderEmail string
|
||||||
SenderName string
|
SenderName string
|
||||||
Signature string
|
Signature string
|
||||||
|
Scopes []string
|
||||||
SmallPhotoURL string
|
SmallPhotoURL string
|
||||||
StartOfDay string
|
StartOfDay string
|
||||||
ExternalAccount string
|
ExternalAccount string
|
||||||
|
|
@ -76,3 +79,22 @@ type User struct {
|
||||||
UserRoleID string
|
UserRoleID string
|
||||||
UserType string
|
UserType string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasScope reports whether a native service principal has an exact mesh
|
||||||
|
// authorization scope. Legacy user principals have no scopes.
|
||||||
|
func (u *User) HasScope(scope string) bool {
|
||||||
|
if u == nil || u.PrincipalType != "service" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, granted := range u.Scopes {
|
||||||
|
if granted == scope {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsServicePrincipal matches stable machine identity, never a bearer secret.
|
||||||
|
func (u *User) IsServicePrincipal(principalKey string) bool {
|
||||||
|
return u != nil && u.PrincipalType == "service" && u.PrincipalKey == principalKey
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestNativeServicePrincipalScope(t *testing.T) {
|
||||||
|
principal := &User{
|
||||||
|
PrincipalType: "service",
|
||||||
|
PrincipalKey: "cache",
|
||||||
|
Scopes: []string{"auth:introspect", "crm:account-contact:load"},
|
||||||
|
}
|
||||||
|
if !principal.IsServicePrincipal("cache") {
|
||||||
|
t.Fatal("expected stable service-principal match")
|
||||||
|
}
|
||||||
|
if !principal.HasScope("crm:account-contact:load") {
|
||||||
|
t.Fatal("expected exact scope grant")
|
||||||
|
}
|
||||||
|
if principal.HasScope("crm:account:write") {
|
||||||
|
t.Fatal("unexpected scope grant")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyUserCannotClaimServiceScope(t *testing.T) {
|
||||||
|
legacy := &User{APIKey: "legacy", Scopes: []string{"crm:account-contact:load"}}
|
||||||
|
if legacy.IsServicePrincipal("cache") || legacy.HasScope("crm:account-contact:load") {
|
||||||
|
t.Fatal("legacy user must not satisfy native service authorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -115,8 +115,9 @@ cmd_install_swagger() {
|
||||||
bin="$(swagger_bin)"
|
bin="$(swagger_bin)"
|
||||||
[[ -x "$bin" ]] || die "go install reported success but ${bin} is not executable"
|
[[ -x "$bin" ]] || die "go install reported success but ${bin} is not executable"
|
||||||
|
|
||||||
local got
|
local got version_output
|
||||||
got="$("$bin" version 2>&1 | head -1)"
|
version_output="$("$bin" version 2>&1)"
|
||||||
|
got="${version_output%%$'\n'*}"
|
||||||
log "swagger version: ${got}"
|
log "swagger version: ${got}"
|
||||||
case "$got" in
|
case "$got" in
|
||||||
*"${SWAGGER_VERSION#v}"*) ;;
|
*"${SWAGGER_VERSION#v}"*) ;;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
info:
|
info:
|
||||||
version: 0.3.0
|
version: 0.4.0
|
||||||
title: "auth"
|
title: "auth"
|
||||||
description: "Authentication Microservice"
|
description: "Authentication Microservice"
|
||||||
termsOfService: "https://salesforcedevops.net/terms/"
|
termsOfService: "https://salesforcedevops.net/terms/"
|
||||||
|
|
@ -272,6 +272,12 @@ definitions:
|
||||||
PortalRole:
|
PortalRole:
|
||||||
description: Portal Role Level
|
description: Portal Role Level
|
||||||
type: string
|
type: string
|
||||||
|
PrincipalKey:
|
||||||
|
description: Stable machine principal key; empty for legacy human principals
|
||||||
|
type: string
|
||||||
|
PrincipalType:
|
||||||
|
description: Principal type, currently service or legacy_user
|
||||||
|
type: string
|
||||||
ProfileID:
|
ProfileID:
|
||||||
description: Profile
|
description: Profile
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -290,6 +296,11 @@ definitions:
|
||||||
Signature:
|
Signature:
|
||||||
description: Email Signature
|
description: Email Signature
|
||||||
type: string
|
type: string
|
||||||
|
Scopes:
|
||||||
|
description: Explicit mesh authorization scopes; never contains credentials
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
SmallPhotoURL:
|
SmallPhotoURL:
|
||||||
description: Small Photo URL
|
description: Small Photo URL
|
||||||
type: string
|
type: string
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
info:
|
info:
|
||||||
version: 0.3.0
|
version: 0.4.0
|
||||||
title: "auth"
|
title: "auth"
|
||||||
description: "Authentication Microservice"
|
description: "Authentication Microservice"
|
||||||
termsOfService: "https://salesforcedevops.net/terms/"
|
termsOfService: "https://salesforcedevops.net/terms/"
|
||||||
|
|
@ -272,6 +272,12 @@ definitions:
|
||||||
PortalRole:
|
PortalRole:
|
||||||
description: Portal Role Level
|
description: Portal Role Level
|
||||||
type: string
|
type: string
|
||||||
|
PrincipalKey:
|
||||||
|
description: Stable machine principal key; empty for legacy human principals
|
||||||
|
type: string
|
||||||
|
PrincipalType:
|
||||||
|
description: Principal type, currently service or legacy_user
|
||||||
|
type: string
|
||||||
ProfileID:
|
ProfileID:
|
||||||
description: Profile
|
description: Profile
|
||||||
type: string
|
type: string
|
||||||
|
|
@ -290,6 +296,11 @@ definitions:
|
||||||
Signature:
|
Signature:
|
||||||
description: Email Signature
|
description: Email Signature
|
||||||
type: string
|
type: string
|
||||||
|
Scopes:
|
||||||
|
description: Explicit mesh authorization scopes; never contains credentials
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
SmallPhotoURL:
|
SmallPhotoURL:
|
||||||
description: Small Photo URL
|
description: Small Photo URL
|
||||||
type: string
|
type: string
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue