diff --git a/README.md b/README.md index 03ec433..a12e27b 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,13 @@ pins the same module, a `lib` bump is a constellation-wide rebuild (KV-C6a). | Path | What it is | |---|---| | `app/root.go` | `InitConfig(systemName, level)` — loads `/etc/vernonkeenan/.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/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/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) | | `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 | diff --git a/api/auth/auth_models/user.go b/api/auth/auth_models/user.go index d2b9274..3136f33 100644 --- a/api/auth/auth_models/user.go +++ b/api/auth/auth_models/user.go @@ -148,6 +148,12 @@ type User struct { // Portal Role Level 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 ProfileID string `json:"ProfileID,omitempty"` @@ -157,6 +163,9 @@ type User struct { // Admin Info Emails ReceivesAdminInfoEmails bool `json:"ReceivesAdminInfoEmails,omitempty"` + // Explicit mesh authorization scopes; never contains credentials + Scopes []string `json:"Scopes"` + // Email Sender Address SenderEmail string `json:"SenderEmail,omitempty"` diff --git a/app/auth.go b/app/auth.go index 73b1b43..4c0b635 100644 --- a/app/auth.go +++ b/app/auth.go @@ -10,7 +10,6 @@ import ( // // When called, ChechAPIUser assumes there is an "auth" service account in the // app configuration file. -// func CheckAPIUser(token *string) (*User, error) { sugar.Debug("app.CheckAPIUser: 📥") if authUser == nil { @@ -27,7 +26,7 @@ func CheckAPIUser(token *string) (*User, error) { params.Apikey = token response, err := authClient.User.GetUsers(params, authUser.Auth) 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 } var obj *auth_models.User diff --git a/app/config.go b/app/config.go index 51d7515..20cff67 100644 --- a/app/config.go +++ b/app/config.go @@ -69,6 +69,7 @@ type ServiceAccount struct { // Configuration defines the config struct type Configuration struct { AppName string `mapstructure:"app_name,omitempty"` + AuthMethod string `mapstructure:"auth_method,omitempty"` BackendID string `mapstructure:"backend_id,omitempty"` BuildEnv string `mapstructure:"build_env,omitempty"` CacheSizes map[string]CacheSize `mapstructure:"cache_sizes,omitempty"` @@ -98,6 +99,11 @@ type Configuration struct { 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 func GetCacheSize(name string) int64 { obj, ok := config.CacheSizes[name] diff --git a/app/user-helpers.go b/app/user-helpers.go index 57c48cf..2e19247 100644 --- a/app/user-helpers.go +++ b/app/user-helpers.go @@ -76,12 +76,15 @@ func MarshalAuthUserToSwagger(obj *auth_models.User) *User { OutOfOfficeMessage: obj.OutOfOfficeMessage, Phone: obj.Phone, PortalRole: obj.PortalRole, + PrincipalKey: obj.PrincipalKey, + PrincipalType: obj.PrincipalType, ProfileID: obj.ProfileID, ReceivesAdminEmails: obj.ReceivesAdminEmails, ReceivesAdminInfoEmails: obj.ReceivesAdminInfoEmails, SenderEmail: obj.SenderEmail, SenderName: obj.SenderName, Signature: obj.Signature, + Scopes: append([]string(nil), obj.Scopes...), SmallPhotoURL: obj.SmallPhotoURL, StartOfDay: obj.StartOfDay, ExternalAccount: obj.ExternalAccount, diff --git a/app/user.go b/app/user.go index a8b052a..0b89ff2 100644 --- a/app/user.go +++ b/app/user.go @@ -57,6 +57,8 @@ type User struct { OutOfOfficeMessage string Phone string PortalRole string + PrincipalKey string + PrincipalType string ProfileID string ReceivesAdminEmails bool ReceivesAdminInfoEmails bool @@ -64,6 +66,7 @@ type User struct { SenderEmail string SenderName string Signature string + Scopes []string SmallPhotoURL string StartOfDay string ExternalAccount string @@ -76,3 +79,22 @@ type User struct { UserRoleID 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 +} diff --git a/app/user_test.go b/app/user_test.go new file mode 100644 index 0000000..2ac719c --- /dev/null +++ b/app/user_test.go @@ -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") + } +} diff --git a/scripts/release.sh b/scripts/release.sh index 1e5cb45..8de156b 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -115,8 +115,9 @@ cmd_install_swagger() { bin="$(swagger_bin)" [[ -x "$bin" ]] || die "go install reported success but ${bin} is not executable" - local got - got="$("$bin" version 2>&1 | head -1)" + local got version_output + version_output="$("$bin" version 2>&1)" + got="${version_output%%$'\n'*}" log "swagger version: ${got}" case "$got" in *"${SWAGGER_VERSION#v}"*) ;; diff --git a/swagger/auth-vernonkeenan.yaml b/swagger/auth-vernonkeenan.yaml index f7f7e80..5152968 100644 --- a/swagger/auth-vernonkeenan.yaml +++ b/swagger/auth-vernonkeenan.yaml @@ -1,6 +1,6 @@ swagger: "2.0" info: - version: 0.3.0 + version: 0.4.0 title: "auth" description: "Authentication Microservice" termsOfService: "https://salesforcedevops.net/terms/" @@ -272,6 +272,12 @@ definitions: PortalRole: description: Portal Role Level 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: description: Profile type: string @@ -290,6 +296,11 @@ definitions: Signature: description: Email Signature type: string + Scopes: + description: Explicit mesh authorization scopes; never contains credentials + items: + type: string + type: array SmallPhotoURL: description: Small Photo URL type: string diff --git a/swagger/external/auth-vernonkeenan.yaml b/swagger/external/auth-vernonkeenan.yaml index d4c8ea8..b67ffc8 100644 --- a/swagger/external/auth-vernonkeenan.yaml +++ b/swagger/external/auth-vernonkeenan.yaml @@ -1,6 +1,6 @@ swagger: "2.0" info: - version: 0.3.0 + version: 0.4.0 title: "auth" description: "Authentication Microservice" termsOfService: "https://salesforcedevops.net/terms/" @@ -272,6 +272,12 @@ definitions: PortalRole: description: Portal Role Level 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: description: Profile type: string @@ -290,6 +296,11 @@ definitions: Signature: description: Email Signature type: string + Scopes: + description: Explicit mesh authorization scopes; never contains credentials + items: + type: string + type: array SmallPhotoURL: description: Small Photo URL type: string