From 23d0fbf0934add82cb3cf634af61785882c6b10d Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 4 Sep 2026 10:08:43 +0100 Subject: [PATCH 01/12] feat: enforce live agent credential admission --- server/internal/audit/logger.go | 14 + server/internal/audit/logger_internal_test.go | 32 ++ server/internal/auth/auth.go | 8 + server/internal/auth/authorize_test.go | 102 +++++ server/internal/auth/key.go | 118 +++++- server/internal/auth/key_test.go | 78 ++++ server/internal/authz/challenge_logger.go | 27 +- .../internal/authz/challenge_logger_test.go | 15 + server/internal/authz/context.go | 87 +++- server/internal/authz/credential_admission.go | 93 +++++ .../authz/credential_admission_test.go | 264 ++++++++++++ server/internal/authz/decision_telemetry.go | 6 + .../internal/authz/decision_telemetry_test.go | 5 +- server/internal/authz/delegated_policy.go | 390 ++++++++++++++++++ .../internal/authz/delegated_policy_test.go | 148 +++++++ server/internal/authz/engine.go | 95 +++-- server/internal/authz/engine_test.go | 124 +++++- server/internal/authz/repo/models.go | 1 + .../internal/authz/requested_organization.go | 7 + server/internal/contextvalues/context.go | 61 ++- server/internal/contextvalues/context_test.go | 25 +- 21 files changed, 1622 insertions(+), 78 deletions(-) create mode 100644 server/internal/audit/logger_internal_test.go create mode 100644 server/internal/authz/credential_admission.go create mode 100644 server/internal/authz/credential_admission_test.go create mode 100644 server/internal/authz/delegated_policy.go create mode 100644 server/internal/authz/delegated_policy_test.go diff --git a/server/internal/audit/logger.go b/server/internal/audit/logger.go index 824bf3e5d4c..98b098be79d 100644 --- a/server/internal/audit/logger.go +++ b/server/internal/audit/logger.go @@ -5,9 +5,11 @@ import ( "fmt" "github.com/speakeasy-api/gram/server/internal/audit/repo" + "github.com/speakeasy-api/gram/server/internal/contextvalues" "github.com/speakeasy-api/gram/server/internal/conv" "github.com/speakeasy-api/gram/server/internal/outbox" "github.com/speakeasy-api/gram/server/internal/outbox/events" + "github.com/speakeasy-api/gram/server/internal/urn" ) type Logger struct{} @@ -28,6 +30,7 @@ type auditEntry struct { // They describe the request, not the event, which is why they are derived from // the context at the moment of the write. func (l *Logger) log(ctx context.Context, dbtx repo.DBTX, entry auditEntry) error { + applyAuthenticatedActor(ctx, &entry.Params) identity := actingIdentityFromContext(ctx) // The column is nullable so that rows predating it need no backfill, but // nothing written from here on is left null: an unattributable write @@ -49,3 +52,14 @@ func (l *Logger) log(ctx context.Context, dbtx repo.DBTX, entry auditEntry) erro return nil } + +func applyAuthenticatedActor(ctx context.Context, params *repo.InsertAuditLogParams) { + actor, ok := contextvalues.AuthenticatedActor(ctx) + if !ok || actor.Type != urn.PrincipalTypeAgent { + return + } + params.ActorID = actor.ID + params.ActorType = string(actor.Type) + params.ActorDisplayName = conv.ToPGTextEmpty("") + params.ActorSlug = conv.ToPGTextEmpty("") +} diff --git a/server/internal/audit/logger_internal_test.go b/server/internal/audit/logger_internal_test.go new file mode 100644 index 00000000000..68fa7f834f6 --- /dev/null +++ b/server/internal/audit/logger_internal_test.go @@ -0,0 +1,32 @@ +package audit + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/speakeasy-api/gram/server/internal/audit/repo" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/conv" + "github.com/speakeasy-api/gram/server/internal/urn" +) + +func TestApplyAuthenticatedActorUsesAgentInsteadOfAuthorizer(t *testing.T) { + t.Parallel() + agent := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + ctx := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &contextvalues.AuthContext{}, agent, contextvalues.PrincipalCredential{ + AuthorizerUserID: "user_authorizer", + }) + params := repo.InsertAuditLogParams{ + ActorID: "user_authorizer", + ActorType: string(urn.PrincipalTypeUser), + ActorDisplayName: conv.ToPGText("Authorizer Name"), + ActorSlug: conv.ToPGText("authorizer-slug"), + } + + applyAuthenticatedActor(ctx, ¶ms) + require.Equal(t, agent.ID, params.ActorID) + require.Equal(t, string(urn.PrincipalTypeAgent), params.ActorType) + require.False(t, params.ActorDisplayName.Valid) + require.False(t, params.ActorSlug.Valid) +} diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 11c86c38088..9a4e291f960 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( "context" "errors" + "fmt" "log/slog" "github.com/google/uuid" @@ -92,8 +93,15 @@ func (s *Auth) authorize( ctx, err = s.authz.PrepareContext(ctx) if err != nil { + var shareable *oops.ShareableError + if errors.As(err, &shareable) && shareable.Code != oops.CodeUnexpected { + return ctx, fmt.Errorf("principal credential admission: %w", err) + } return ctx, oops.E(oops.CodeUnexpected, err, "load access grants").LogError(ctx, s.logger) } + if scheme.Name == constants.KeySecurityScheme { + s.keys.TouchPrincipalAPIKey(ctx) + } // After resolving Gram-Project, require the caller holds project:read on // that project. When RBAC is off (or the caller is an API key), Require is diff --git a/server/internal/auth/authorize_test.go b/server/internal/auth/authorize_test.go index cf70e69b09b..40d11c9b691 100644 --- a/server/internal/auth/authorize_test.go +++ b/server/internal/auth/authorize_test.go @@ -2,13 +2,16 @@ package auth_test import ( "context" + "sync" "testing" + "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "goa.design/goa/v3/security" accessrepo "github.com/speakeasy-api/gram/server/internal/access/repo" + agentsrepo "github.com/speakeasy-api/gram/server/internal/agents/repo" "github.com/speakeasy-api/gram/server/internal/attr" "github.com/speakeasy-api/gram/server/internal/auth" "github.com/speakeasy-api/gram/server/internal/auth/sessions" @@ -237,6 +240,91 @@ func TestAuthorizeProjectBoundKeyAllowsEmptySlugForSingleProjectOrganization(t * require.Equal(t, projects[0].Slug, *authCtx.ProjectSlug) } +func TestAuthorizePrincipalAPIKeyUsesLiveAgentAdmission(t *testing.T) { + t.Parallel() + + ctx, instance, projects := newProjectAccessTest(t, "agent-project") + userInfo := defaultMockUserInfo() + organizationID := userInfo.Organizations[0].ID + ownerUserID := userInfo.UserID + key := createTestAPIKey(t, ctx, instance, nil) + keyHash, err := auth.GetAPIKeyHash(key) + require.NoError(t, err) + + agent, err := agentsrepo.New(instance.conn).CreateAgent(ctx, agentsrepo.CreateAgentParams{ + OrganizationID: organizationID, OwnerUserID: ownerUserID, Name: "Principal key agent", + }) + require.NoError(t, err) + projectID := projects[0].ID.String() + seedUserProjectGrant(t, ctx, instance, organizationID, ownerUserID, projectID) + seedPrincipalProjectGrant(t, ctx, instance, organizationID, urn.NewPrincipal(urn.PrincipalTypeAgent, agent.ID.String()), projectID) + + policy, err := authz.NewDelegatedPolicyV1([]authz.Grant{authz.NewGrant(authz.ScopeProjectRead, projectID)}) + require.NoError(t, err) + rawPolicy, err := authz.EncodeDelegatedPolicy(authz.CurrentDelegatedPolicyVersion, policy) + require.NoError(t, err) + //nolint:glint // notestingrawsql: AIM-194 owns the future principal-key writer; this exercises the loaded-row admission path only + _, err = instance.conn.Exec(ctx, `UPDATE api_keys SET scopes = '{}', subject_urn = $1, delegated_grants = $2, delegated_grants_version = $3, expires_at = $4 WHERE key_hash = $5`, + "agent:"+agent.ID.String(), rawPolicy, int32(authz.CurrentDelegatedPolicyVersion), time.Now().Add(24*time.Hour), keyHash) + require.NoError(t, err) + + admitted, err := instance.authorizer.Authorize(ctx, key, apiKeyScheme) + require.NoError(t, err, "principal authorization ignores legacy transport scopes") + authCtx, ok := contextvalues.GetAuthContext(admitted) + require.True(t, ok) + require.Empty(t, authCtx.UserID) + require.Nil(t, authCtx.Email) + actor, ok := contextvalues.AuthenticatedActor(admitted) + require.True(t, ok) + require.Equal(t, "agent:"+agent.ID.String(), actor.String()) + authorizer, owner, ok := contextvalues.PrincipalCredentialProvenance(admitted) + require.True(t, ok) + require.Equal(t, ownerUserID, authorizer) + require.Equal(t, ownerUserID, owner) + _, err = instance.authorizer.Authorize(admitted, projects[0].Slug, projectSlugScheme) + require.NoError(t, err) + + legacyAgentScheme := &security.APIKeyScheme{Name: constants.KeySecurityScheme, RequiredScopes: []string{"agent"}} + _, err = instance.authorizer.Authorize(ctx, key, legacyAgentScheme) + var legacyRouteErr *oops.ShareableError + require.ErrorAs(t, err, &legacyRouteErr) + require.Equal(t, oops.CodeForbidden, legacyRouteErr.Code, "principal credentials cannot enter legacy scope-only agent routes") + + _, err = agentsrepo.New(instance.conn).SuspendAgent(ctx, agentsrepo.SuspendAgentParams{OrganizationID: organizationID, ID: agent.ID}) + require.NoError(t, err) + _, err = instance.authorizer.Authorize(ctx, key, apiKeyScheme) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeUnauthorized, oopsErr.Code) + + _, err = agentsrepo.New(instance.conn).ResumeAgent(ctx, agentsrepo.ResumeAgentParams{OrganizationID: organizationID, ID: agent.ID}) + require.NoError(t, err) + _, err = instance.authorizer.Authorize(ctx, key, apiKeyScheme) + require.NoError(t, err) + apiKey, err := keysrepo.New(instance.conn).GetAPIKeyByKeyHash(ctx, keyHash) + require.NoError(t, err) + _, err = keysrepo.New(instance.conn).DeleteAPIKey(ctx, keysrepo.DeleteAPIKeyParams{ID: apiKey.ID, OrganizationID: organizationID}) + require.NoError(t, err) + + start := make(chan struct{}) + results := make(chan error, 32) + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + <-start + _, err := instance.authorizer.Authorize(ctx, key, apiKeyScheme) + results <- err + }) + } + close(start) + workers.Wait() + close(results) + for err := range results { + require.ErrorAs(t, err, &oopsErr, "no admission may succeed after direct credential revocation commits") + require.Equal(t, oops.CodeUnauthorized, oopsErr.Code) + } +} + func newProjectAccessTest(t *testing.T, projectSlugs ...string) (context.Context, *testInstance, []projectsrepo.Project) { t.Helper() @@ -282,6 +370,20 @@ func createTestAPIKey(t *testing.T, ctx context.Context, instance *testInstance, return key } +func seedPrincipalProjectGrant(t *testing.T, ctx context.Context, instance *testInstance, organizationID string, principal urn.Principal, projectID string) { + t.Helper() + + selectors, err := authz.NewSelector(authz.ScopeProjectRead, projectID).MarshalJSON() + require.NoError(t, err) + _, err = accessrepo.New(instance.conn).UpsertPrincipalGrant(ctx, accessrepo.UpsertPrincipalGrantParams{ + OrganizationID: organizationID, + PrincipalUrn: principal, + Scope: string(authz.ScopeProjectRead), + Selectors: selectors, + }) + require.NoError(t, err) +} + func seedUserProjectGrant(t *testing.T, ctx context.Context, instance *testInstance, organizationID string, userID string, projectID string) { t.Helper() diff --git a/server/internal/auth/key.go b/server/internal/auth/key.go index fad6b60be73..c03ceacfc9c 100644 --- a/server/internal/auth/key.go +++ b/server/internal/auth/key.go @@ -22,6 +22,7 @@ import ( "github.com/speakeasy-api/gram/server/internal/mv" "github.com/speakeasy-api/gram/server/internal/oops" orgRepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" + "github.com/speakeasy-api/gram/server/internal/urn" ) type APIKeyScope int @@ -48,6 +49,8 @@ const ( // flows. Historical user-created keys may still carry this prefix, so callers // classifying org-wide hook keys must verify the token/name minting marker. const ( + maxAgentAPIKeyLifetime = 365 * 24 * time.Hour + PluginAPIKeyNamePrefix = "plugins-" LiteLLMAPIKeyNamePrefix = "litellm-" ) @@ -290,21 +293,25 @@ func (k *ByKey) KeyBasedAuth(ctx context.Context, key string, requiredScopes []s return ctx, oops.E(oops.CodeUnexpected, err, "error loading api key details") } - // LiteLLM keys are touched only after project-header authorization succeeds. - // This keeps rejected project mismatches out of customer-visible last use. - if !IsLiteLLMAPIKeyName(apiKey.Name) { - if err := k.keyDB.UpdateAPIKeyLastAccessedAt(ctx, apiKey.ID); err != nil { - logger.WarnContext(ctx, "failed to update api key last accessed at", - attr.SlogError(err), - attr.SlogOrganizationID(apiKey.OrganizationID), - ) - } + actor, credential, principalBacked, err := classifyPrincipalAPIKey(apiKey, time.Now()) + if err != nil { + return ctx, oops.C(oops.CodeUnauthorized) } - scopes := effectiveScopes(apiKey.Scopes) - for _, scope := range requiredScopes { - if !slices.Contains(scopes, scope) { - return ctx, oops.E(oops.CodeForbidden, nil, "api key insufficient scopes") + scopes := []string(nil) + if principalBacked && !principalAPIKeySupportsTransportScopes(requiredScopes) { + return ctx, oops.C(oops.CodeForbidden) + } + if !principalBacked { + // LiteLLM keys are touched only after project-header authorization succeeds. + // This keeps rejected project mismatches out of customer-visible last use. + k.touchAPIKey(ctx, apiKey.ID, apiKey.Name, apiKey.OrganizationID) + + scopes = effectiveScopes(apiKey.Scopes) + for _, scope := range requiredScopes { + if !slices.Contains(scopes, scope) { + return ctx, oops.E(oops.CodeForbidden, nil, "api key insufficient scopes") + } } } @@ -322,12 +329,21 @@ func (k *ByKey) KeyBasedAuth(ctx context.Context, key string, requiredScopes []s projectID = &apiKey.ProjectID.UUID } - ctx = contextvalues.WithLegacyAPIKeyAuthorization(ctx, &contextvalues.AuthContext{ + userID := apiKey.CreatedByUserID + email := &apiKey.Email + if principalBacked { + // Human-shaped fields must not make the immutable authorizer look like the + // runtime actor. Principal-safe consumers use AuthenticatedActor and the + // separate trusted provenance fields. + userID = "" + email = nil + } + authCtx := &contextvalues.AuthContext{ ActiveOrganizationID: apiKey.OrganizationID, HasActiveSubscription: org.HasActiveSubscription, Whitelisted: org.Whitelisted, - UserID: apiKey.CreatedByUserID, - Email: &apiKey.Email, + UserID: userID, + Email: email, APIKeyID: apiKey.ID.String(), APIKeyName: apiKey.Name, OrgWidePluginHooksKey: IsOrgWidePluginHooksAPIKey(apiKey.Name, key, apiKey.KeyPrefix), @@ -340,7 +356,75 @@ func (k *ByKey) KeyBasedAuth(ctx context.Context, key string, requiredScopes []s ProjectSlug: nil, IsAdmin: false, SupportOrganizationID: "", - }) + } + if principalBacked { + ctx = contextvalues.WithPrincipalAPIKeyAuthorization(ctx, authCtx, actor, credential) + } else { + ctx = contextvalues.WithLegacyAPIKeyAuthorization(ctx, authCtx) + } return ctx, nil } + +func principalAPIKeySupportsTransportScopes(requiredScopes []string) bool { + if len(requiredScopes) == 0 { + return false + } + for _, scope := range requiredScopes { + if scope != APIKeyScopeProducer.String() && scope != APIKeyScopeConsumer.String() { + return false + } + } + return true +} + +func classifyPrincipalAPIKey(apiKey repo.GetAPIKeyByKeyHashRow, now time.Time) (urn.Principal, contextvalues.PrincipalCredential, bool, error) { + var emptyPrincipal urn.Principal + profilePresent := apiKey.SubjectUrn.Valid || apiKey.DelegatedGrants != nil || apiKey.DelegatedGrantsVersion.Valid || apiKey.ExpiresAt.Valid + if !profilePresent { + return emptyPrincipal, contextvalues.PrincipalCredential{AuthorizerUserID: "", DelegatedGrants: nil, DelegatedGrantsVersion: 0}, false, nil + } + if !apiKey.SubjectUrn.Valid || apiKey.DelegatedGrants == nil || !apiKey.DelegatedGrantsVersion.Valid || !apiKey.ExpiresAt.Valid || !apiKey.CreatedAt.Valid || + len(apiKey.Scopes) != 0 || apiKey.CreatedByUserID == "" || !apiKey.ExpiresAt.Time.After(now) || + !apiKey.ExpiresAt.Time.After(apiKey.CreatedAt.Time) || apiKey.ExpiresAt.Time.After(apiKey.CreatedAt.Time.Add(maxAgentAPIKeyLifetime)) { + return emptyPrincipal, contextvalues.PrincipalCredential{AuthorizerUserID: "", DelegatedGrants: nil, DelegatedGrantsVersion: 0}, true, errors.New("invalid principal-backed api key profile") + } + + actor, err := urn.ParsePrincipal(apiKey.SubjectUrn.String) + if err != nil || actor.Type != urn.PrincipalTypeAgent { + return emptyPrincipal, contextvalues.PrincipalCredential{AuthorizerUserID: "", DelegatedGrants: nil, DelegatedGrantsVersion: 0}, true, errors.New("invalid principal-backed api key subject") + } + + return actor, contextvalues.PrincipalCredential{ + AuthorizerUserID: apiKey.CreatedByUserID, + DelegatedGrants: apiKey.DelegatedGrants, + DelegatedGrantsVersion: apiKey.DelegatedGrantsVersion.Int32, + }, true, nil +} + +func (k *ByKey) touchAPIKey(ctx context.Context, id uuid.UUID, name, organizationID string) { + if IsLiteLLMAPIKeyName(name) { + return + } + if err := k.keyDB.UpdateAPIKeyLastAccessedAt(ctx, id); err != nil { + k.logger.WarnContext(ctx, "failed to update api key last accessed at", + attr.SlogError(err), + attr.SlogOrganizationID(organizationID), + ) + } +} + +// TouchPrincipalAPIKey records use only after principal-backed parent admission +// has succeeded. Rejected profiles and inactive parents are not counted as use. +func (k *ByKey) TouchPrincipalAPIKey(ctx context.Context) { + mode, ok := contextvalues.APIKeyAuthorization(ctx) + authCtx, hasAuth := contextvalues.GetAuthContext(ctx) + if !ok || mode != contextvalues.APIKeyAuthorizationModePrincipal || !hasAuth || authCtx == nil { + return + } + id, err := uuid.Parse(authCtx.APIKeyID) + if err != nil { + return + } + k.touchAPIKey(ctx, id, authCtx.APIKeyName, authCtx.ActiveOrganizationID) +} diff --git a/server/internal/auth/key_test.go b/server/internal/auth/key_test.go index 0336af09548..cc3d2ff25a3 100644 --- a/server/internal/auth/key_test.go +++ b/server/internal/auth/key_test.go @@ -8,9 +8,87 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/require" + + keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" ) +func TestClassifyPrincipalAPIKeyUsesOnlyCompleteLoadedProfile(t *testing.T) { + t.Parallel() + now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC) + createdAt := now.Add(-time.Hour) + valid := keysrepo.GetAPIKeyByKeyHashRow{ + CreatedByUserID: "user_authorizer", + SubjectUrn: pgtype.Text{String: "agent:018f8d7b-58d7-7cc4-bb16-9f8c6b99a001", Valid: true}, + DelegatedGrants: []byte(`{"requested":[],"effective":[]}`), + DelegatedGrantsVersion: pgtype.Int4{Int32: 1, Valid: true}, + ExpiresAt: pgtype.Timestamptz{Time: now.Add(time.Hour), Valid: true}, + CreatedAt: pgtype.Timestamptz{Time: createdAt, Valid: true}, + } + + actor, credential, principal, err := classifyPrincipalAPIKey(valid, now) + require.NoError(t, err) + require.True(t, principal) + require.Equal(t, valid.SubjectUrn.String, actor.String()) + require.Equal(t, valid.CreatedByUserID, credential.AuthorizerUserID) + require.Equal(t, valid.DelegatedGrants, credential.DelegatedGrants) + require.Equal(t, int32(1), credential.DelegatedGrantsVersion) + + legacy := keysrepo.GetAPIKeyByKeyHashRow{Scopes: []string{APIKeyScopeAgent.String()}} + _, _, principal, err = classifyPrincipalAPIKey(legacy, now) + require.NoError(t, err) + require.False(t, principal, "legacy scope names cannot select principal authorization") + + invalid := map[string]keysrepo.GetAPIKeyByKeyHashRow{ + "subject only": func() keysrepo.GetAPIKeyByKeyHashRow { + row := keysrepo.GetAPIKeyByKeyHashRow{} + row.SubjectUrn = valid.SubjectUrn + return row + }(), + "policy only": {DelegatedGrants: valid.DelegatedGrants}, + "non-agent subject": func() keysrepo.GetAPIKeyByKeyHashRow { + row := valid + row.SubjectUrn.String = "user:user_123" + return row + }(), + "malformed subject": func() keysrepo.GetAPIKeyByKeyHashRow { + row := valid + row.SubjectUrn.String = "agent:not-a-uuid" + return row + }(), + "legacy scopes present": func() keysrepo.GetAPIKeyByKeyHashRow { + row := valid + row.Scopes = []string{APIKeyScopeProducer.String()} + return row + }(), + "expired": func() keysrepo.GetAPIKeyByKeyHashRow { row := valid; row.ExpiresAt.Time = now; return row }(), + "exceeds maximum lifetime": func() keysrepo.GetAPIKeyByKeyHashRow { + row := valid + row.ExpiresAt.Time = createdAt.Add(maxAgentAPIKeyLifetime + time.Second) + return row + }(), + } + for name, row := range invalid { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, _, principal, err := classifyPrincipalAPIKey(row, now) + require.True(t, principal) + require.Error(t, err) + }) + } +} + +func TestPrincipalAPIKeySupportsOnlyPrincipalSafeTransportRoutes(t *testing.T) { + t.Parallel() + require.False(t, principalAPIKeySupportsTransportScopes(nil)) + require.True(t, principalAPIKeySupportsTransportScopes([]string{"consumer"})) + require.True(t, principalAPIKeySupportsTransportScopes([]string{"producer"})) + for _, scope := range []string{"agent", "agent_user", "chat", "hooks", "unknown"} { + require.False(t, principalAPIKeySupportsTransportScopes([]string{scope}), scope) + } +} + // TestEffectiveScopes pins the one-way scope implications, especially the // device-agent split: an org `agent` install key implies `agent_user` (so it // still reads the data endpoints during the transition), but a per-user diff --git a/server/internal/authz/challenge_logger.go b/server/internal/authz/challenge_logger.go index 1ee0c2a9def..d1aae4b82cc 100644 --- a/server/internal/authz/challenge_logger.go +++ b/server/internal/authz/challenge_logger.go @@ -82,15 +82,7 @@ func (l challengeLogger) Log(ctx context.Context, dbtx database.DBTX, logger *sl return } - principalURN := urn.NewPrincipal(urn.PrincipalTypeUser, authCtx.UserID).String() - principalType := authzrepo.PrincipalTypeUser - if authCtx.APIKeyID != "" { - principalURN = "api_key:" + authCtx.APIKeyID - principalType = authzrepo.PrincipalTypeAPIKey - } - if _, isAssistant := contextvalues.GetAssistantPrincipal(ctx); isAssistant { - principalType = authzrepo.PrincipalTypeAssistant - } + principalURN, principalType := authorizationChallengePrincipal(ctx, authCtx) var focus Check var focusSelector Selector @@ -212,6 +204,23 @@ func (l challengeLogger) Log(ctx context.Context, dbtx database.DBTX, logger *sl } } +func authorizationChallengePrincipal(ctx context.Context, authCtx *contextvalues.AuthContext) (string, authzrepo.PrincipalType) { + principalURN := urn.NewPrincipal(urn.PrincipalTypeUser, authCtx.UserID).String() + principalType := authzrepo.PrincipalTypeUser + if authCtx.APIKeyID != "" { + principalURN = "api_key:" + authCtx.APIKeyID + principalType = authzrepo.PrincipalTypeAPIKey + } + if _, isAssistant := contextvalues.GetAssistantPrincipal(ctx); isAssistant { + principalType = authzrepo.PrincipalTypeAssistant + } + if actor, ok := contextvalues.AuthenticatedActor(ctx); ok && actor.Type == urn.PrincipalTypeAgent { + principalURN = actor.String() + principalType = authzrepo.PrincipalTypeAgent + } + return principalURN, principalType +} + func marshalSelector(v Selector) string { if v == nil { return "" diff --git a/server/internal/authz/challenge_logger_test.go b/server/internal/authz/challenge_logger_test.go index fd53f548f7f..683901d4c87 100644 --- a/server/internal/authz/challenge_logger_test.go +++ b/server/internal/authz/challenge_logger_test.go @@ -12,6 +12,7 @@ import ( "github.com/speakeasy-api/gram/server/internal/contextvalues" "github.com/speakeasy-api/gram/server/internal/testenv" "github.com/speakeasy-api/gram/server/internal/testenv/testrepo" + "github.com/speakeasy-api/gram/server/internal/urn" ) func TestChallengeLogger_skipsWithoutAuthContext(t *testing.T) { @@ -148,6 +149,20 @@ func TestChallengeLogger_publishesAPIKeyPrincipal(t *testing.T) { require.Equal(t, "user_owner", message.GetUserId()) } +func TestAuthorizationChallengePrincipalUsesCanonicalAgentActor(t *testing.T) { + t.Parallel() + + agent := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + authCtx := &contextvalues.AuthContext{UserID: "", APIKeyID: "key_abc"} + ctx := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), authCtx, agent, contextvalues.PrincipalCredential{ + AuthorizerUserID: "user_authorizer", DelegatedGrants: []byte(`{"requested":[],"effective":[]}`), DelegatedGrantsVersion: 1, + }) + + principalURN, principalType := authorizationChallengePrincipal(ctx, authCtx) + require.Equal(t, agent.String(), principalURN) + require.Equal(t, authzrepo.PrincipalTypeAgent, principalType) +} + func TestChallengeLogger_publishesAssistantPrincipal(t *testing.T) { t.Parallel() diff --git a/server/internal/authz/context.go b/server/internal/authz/context.go index 186da2ab566..b76d913fcc8 100644 --- a/server/internal/authz/context.go +++ b/server/internal/authz/context.go @@ -1,10 +1,27 @@ package authz -import "context" +import ( + "context" + + "github.com/speakeasy-api/gram/server/internal/contextvalues" +) type contextKey string -const grantsContextKey contextKey = "authz_grants" +const ( + grantsContextKey contextKey = "authz_grants" + principalCredentialPoliciesContextKey contextKey = "authz_principal_credential_policies" //nolint:gosec // private context key, not credential material +) + +type principalCredentialPolicies struct { + credential []Grant + agent []Grant + owner []Grant +} + +type grantAuthorization struct { + policies [][]Grant +} // GrantsToContext stores resolved grants on the request context. func GrantsToContext(ctx context.Context, grants []Grant) context.Context { @@ -16,3 +33,69 @@ func GrantsFromContext(ctx context.Context) ([]Grant, bool) { grants, ok := ctx.Value(grantsContextKey).([]Grant) return grants, ok } + +func principalCredentialPoliciesToContext(ctx context.Context, credential, agent, owner []Grant) context.Context { + policies := principalCredentialPolicies{ + credential: append([]Grant(nil), credential...), + agent: append([]Grant(nil), agent...), + owner: append([]Grant(nil), owner...), + } + return context.WithValue(ctx, principalCredentialPoliciesContextKey, policies) +} + +func grantAuthorizationFromContext(ctx context.Context) (grantAuthorization, bool) { + if policies, ok := ctx.Value(principalCredentialPoliciesContextKey).(principalCredentialPolicies); ok { + return grantAuthorization{policies: [][]Grant{policies.credential, policies.agent, policies.owner}}, true + } + if _, principalCredential := contextvalues.PrincipalCredentialAuthorization(ctx); principalCredential { + return grantAuthorization{policies: nil}, false + } + if mode, ok := contextvalues.APIKeyAuthorization(ctx); ok && mode == contextvalues.APIKeyAuthorizationModePrincipal { + return grantAuthorization{policies: nil}, false + } + grants, ok := GrantsFromContext(ctx) + if !ok { + return grantAuthorization{policies: nil}, false + } + return grantAuthorization{policies: [][]Grant{grants}}, true +} + +func loadedGrantAuthorization(grants []Grant) grantAuthorization { + return grantAuthorization{policies: [][]Grant{grants}} +} + +func (a grantAuthorization) grantCount() int { + total := 0 + for _, policy := range a.policies { + total += len(policy) + } + return total +} + +func (a grantAuthorization) evaluate(check Check) (grantCheckEvaluation, error) { + if len(a.policies) == 0 { + return grantCheckEvaluation{Grant: nil, Check: nil, Denied: false}, nil + } + + allowed := true + denied := false + var representative grantCheckEvaluation + for _, policy := range a.policies { + evaluation, err := evaluateGrantCheck(policy, check) + if err != nil { + return grantCheckEvaluation{Grant: nil, Check: nil, Denied: false}, err + } + denied = denied || evaluation.Denied + if evaluation.Grant == nil { + allowed = false + continue + } + if representative.Grant == nil { + representative = evaluation + } + } + if !allowed { + return grantCheckEvaluation{Grant: nil, Check: nil, Denied: denied}, nil + } + return representative, nil +} diff --git a/server/internal/authz/credential_admission.go b/server/internal/authz/credential_admission.go new file mode 100644 index 00000000000..ac45d841d47 --- /dev/null +++ b/server/internal/authz/credential_admission.go @@ -0,0 +1,93 @@ +package authz + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/speakeasy-api/gram/server/internal/agents" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/o11y" + "github.com/speakeasy-api/gram/server/internal/oops" + "github.com/speakeasy-api/gram/server/internal/urn" +) + +// AdmitPrincipalCredential performs authoritative parent admission and loads +// immutable credential policy R, live direct agent policy A, and the current +// owner's live policy O as three independent policy sets. The caller must first +// load and validate the directly active credential row, stamp its immutable +// profile with contextvalues.WithPrincipalCredentialAuthorization, and call +// this method before minting credentials, resolving upstream authority, or +// executing an operation. Successful results must not be cached across requests. +func (e *Engine) AdmitPrincipalCredential(ctx context.Context) (context.Context, error) { + authCtx, ok := contextvalues.GetAuthContext(ctx) + credential, hasCredential := contextvalues.PrincipalCredentialAuthorization(ctx) + actor, hasActor := contextvalues.AuthenticatedActor(ctx) + if !ok || authCtx == nil || !hasCredential || !hasActor || + authCtx.ActiveOrganizationID == "" || credential.AuthorizerUserID == "" || actor.Type != urn.PrincipalTypeAgent { + return ctx, oops.C(oops.CodeUnauthorized) + } + + policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion(credential.DelegatedGrantsVersion), credential.DelegatedGrants) + if err != nil { + if errors.Is(err, ErrInvalidDelegatedPolicy) { + return ctx, oops.C(oops.CodeUnauthorized) + } + return ctx, fmt.Errorf("decode delegated credential policy: %w", err) + } + + tx, err := e.db.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly, DeferrableMode: pgx.NotDeferrable, BeginQuery: "", CommitQuery: "", + }) + if err != nil { + return ctx, fmt.Errorf("begin credential admission snapshot: %w", err) + } + defer o11y.NoLogDefer(func() error { return tx.Rollback(ctx) }) + + agent, err := agents.ResolvePrincipal(ctx, tx, authCtx.ActiveOrganizationID, actor) + if err != nil { + if errors.Is(err, agents.ErrPrincipalInvalid) || errors.Is(err, agents.ErrPrincipalNotFound) { + return ctx, oops.C(oops.CodeUnauthorized) + } + return ctx, fmt.Errorf("resolve credential parent: %w", err) + } + if agents.DeriveLifecycle(agent) != agents.LifecycleActive || agent.OwnerReassignmentRequiredAt.Valid { + return ctx, oops.C(oops.CodeUnauthorized) + } + + ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, agent.OwnerUserID) + ownerPrincipals, err := ResolveUserPrincipals(ctx, tx, authCtx.ActiveOrganizationID, agent.OwnerUserID) + if err != nil { + if errors.Is(err, ErrPrincipalInvalid) || errors.Is(err, ErrPrincipalNotFound) { + return ctx, oops.C(oops.CodeUnauthorized) + } + return ctx, fmt.Errorf("resolve credential owner: %w", err) + } + ownerEligible := false + for _, principal := range ownerPrincipals { + if principal.String() == ownerPrincipal.String() { + ownerEligible = true + break + } + } + if !ownerEligible { + return ctx, oops.C(oops.CodeUnauthorized) + } + + agentPolicy, err := loadResolvedAgentPolicy(ctx, tx, authCtx.ActiveOrganizationID, actor) + if err != nil { + return ctx, fmt.Errorf("load live agent policy: %w", err) + } + ownerPolicy, err := LoadGrants(ctx, tx, authCtx.ActiveOrganizationID, ownerPrincipals) + if err != nil { + return ctx, fmt.Errorf("load live owner policy: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return ctx, fmt.Errorf("commit credential admission snapshot: %w", err) + } + + ctx = contextvalues.WithPrincipalCredentialOwner(ctx, agent.OwnerUserID) + return principalCredentialPoliciesToContext(ctx, policy.RuntimeGrants(), agentPolicy, ownerPolicy), nil +} diff --git a/server/internal/authz/credential_admission_test.go b/server/internal/authz/credential_admission_test.go new file mode 100644 index 00000000000..e535265a57a --- /dev/null +++ b/server/internal/authz/credential_admission_test.go @@ -0,0 +1,264 @@ +package authz + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + agentsrepo "github.com/speakeasy-api/gram/server/internal/agents/repo" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/conv" + "github.com/speakeasy-api/gram/server/internal/oops" + orgrepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" + "github.com/speakeasy-api/gram/server/internal/testenv" + "github.com/speakeasy-api/gram/server/internal/thirdparty/workos" + "github.com/speakeasy-api/gram/server/internal/urn" + usersrepo "github.com/speakeasy-api/gram/server/internal/users/repo" +) + +func TestPrincipalCredentialAdmissionRejectsEachParentGate(t *testing.T) { + t.Parallel() + + mutations := map[string]func(t *testing.T, fixture credentialAdmissionFixture){ + "suspended agent": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).SuspendAgent(t.Context(), agentsrepo.SuspendAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) + require.NoError(t, err) + }, + "revoked agent": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).RevokeAgent(t.Context(), agentsrepo.RevokeAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) + require.NoError(t, err) + }, + "deleted agent": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).DeleteAgent(t.Context(), agentsrepo.DeleteAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) + require.NoError(t, err) + }, + "owner reassignment required": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).LatchAgentsForOwnerLossByMembership(t.Context(), agentsrepo.LatchAgentsForOwnerLossByMembershipParams{ + OwnerReassignmentReason: pgtype.Text{String: "membership_loss", Valid: true}, + OrganizationID: fixture.organizationID, + OwnerUserID: fixture.ownerUserID, + }) + require.NoError(t, err) + }, + "owner ineligible": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + err := orgrepo.New(fixture.db).DeleteOrganizationUserRelationship(t.Context(), orgrepo.DeleteOrganizationUserRelationshipParams{ + OrganizationID: fixture.organizationID, + UserID: conv.ToPGText(fixture.ownerUserID), + }) + require.NoError(t, err) + }, + } + + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newCredentialAdmissionFixture(t) + mutate(t, fixture) + _, err := fixture.engine.PrepareContext(fixture.requestContext) + requireUnauthorized(t, err) + }) + } +} + +func TestPrincipalCredentialAdmissionIsTenantBound(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + otherOrganizationID := "org-admission-other-" + uuid.NewString() + seedOrganization(t, t.Context(), fixture.db, otherOrganizationID) + authCtx, ok := contextvalues.GetAuthContext(fixture.requestContext) + require.True(t, ok) + crossTenant := *authCtx + crossTenant.ActiveOrganizationID = otherOrganizationID + credential, ok := contextvalues.PrincipalCredentialAuthorization(fixture.requestContext) + require.True(t, ok) + actor, ok := contextvalues.AuthenticatedActor(fixture.requestContext) + require.True(t, ok) + ctx := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &crossTenant, actor, credential) + + _, err := fixture.engine.PrepareContext(ctx) + requireUnauthorized(t, err) +} + +func TestPrincipalCredentialAdmissionReloadsLivePolicies(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + check := Check{Scope: ScopeProjectRead, ResourceID: fixture.projectID} + + prepared, err := fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + require.NoError(t, fixture.engine.Require(prepared, check)) + actor, ok := contextvalues.AuthenticatedActor(prepared) + require.True(t, ok) + require.Equal(t, "agent:"+fixture.agentID.String(), actor.String()) + authorizer, owner, ok := contextvalues.PrincipalCredentialProvenance(prepared) + require.True(t, ok) + require.Equal(t, fixture.authorizerUserID, authorizer) + require.Equal(t, fixture.ownerUserID, owner) + + for name, principalURN := range map[string]func(credentialAdmissionFixture) string{ + "agent A": func(f credentialAdmissionFixture) string { return "agent:" + f.agentID.String() }, + "owner O": func(f credentialAdmissionFixture) string { return "user:" + f.ownerUserID }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newCredentialAdmissionFixture(t) + principal := principalURN(fixture) + //nolint:glint // notestingrawsql: simulate an authoritative live-policy removal and restoration + _, err := fixture.db.Exec(t.Context(), `DELETE FROM principal_grants WHERE organization_id = $1 AND principal_urn = $2 AND scope = $3`, fixture.organizationID, principal, string(ScopeProjectRead)) + require.NoError(t, err) + + prepared, err := fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + var denied *oops.ShareableError + require.ErrorAs(t, fixture.engine.Require(prepared, check), &denied) + require.Equal(t, oops.CodeForbidden, denied.Code) + + parsed, err := urn.ParsePrincipal(principal) + require.NoError(t, err) + seedGrant(t, t.Context(), fixture.db, fixture.organizationID, parsed, ScopeProjectRead, fixture.projectID) + prepared, err = fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + require.NoError(t, fixture.engine.Require(prepared, check), "restoration may reactivate authority still present in immutable R") + }) + } +} + +func TestPrincipalCredentialAdmissionUsesOnlyCurrentOwnerAfterTransfer(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + newOwnerUserID := "new-owner-" + uuid.NewString() + _, err := usersrepo.New(fixture.db).UpsertUser(t.Context(), usersrepo.UpsertUserParams{ + ID: newOwnerUserID, Email: newOwnerUserID + "@example.com", DisplayName: newOwnerUserID, PhotoUrl: conv.PtrToPGText(nil), Admin: false, + }) + require.NoError(t, err) + _, err = orgrepo.New(fixture.db).UpsertOrganizationUserRelationship(t.Context(), orgrepo.UpsertOrganizationUserRelationshipParams{ + OrganizationID: fixture.organizationID, UserID: conv.ToPGText(newOwnerUserID), + }) + require.NoError(t, err) + _, err = agentsrepo.New(fixture.db).TransferAgent(t.Context(), agentsrepo.TransferAgentParams{ + OwnerUserID: newOwnerUserID, OrganizationID: fixture.organizationID, ID: fixture.agentID, + }) + require.NoError(t, err) + + check := Check{Scope: ScopeProjectRead, ResourceID: fixture.projectID} + prepared, err := fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + var denied *oops.ShareableError + require.ErrorAs(t, fixture.engine.Require(prepared, check), &denied, "former owner's grants cannot authorize after transfer") + require.Equal(t, oops.CodeForbidden, denied.Code) + + seedGrant(t, t.Context(), fixture.db, fixture.organizationID, urn.NewPrincipal(urn.PrincipalTypeUser, newOwnerUserID), ScopeProjectRead, fixture.projectID) + prepared, err = fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + require.NoError(t, fixture.engine.Require(prepared, check)) + _, owner, ok := contextvalues.PrincipalCredentialProvenance(prepared) + require.True(t, ok) + require.Equal(t, newOwnerUserID, owner) +} + +func TestPrincipalCredentialAdmissionDeniesEveryCheckAfterRevocationCommit(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + start := make(chan struct{}) + results := make(chan error, 32) + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + <-start + _, err := fixture.engine.PrepareContext(fixture.requestContext) + results <- err + }) + } + + _, err := agentsrepo.New(fixture.db).RevokeAgent(t.Context(), agentsrepo.RevokeAgentParams{ + OrganizationID: fixture.organizationID, + ID: fixture.agentID, + }) + require.NoError(t, err) + close(start) + workers.Wait() + close(results) + for err := range results { + requireUnauthorized(t, err) + } +} + +type credentialAdmissionFixture struct { + db *pgxpool.Pool + engine *Engine + requestContext context.Context //nolint:containedctx // immutable request authentication fixture + organizationID string + ownerUserID string + authorizerUserID string + agentID uuid.UUID + projectID string +} + +func newCredentialAdmissionFixture(t *testing.T) credentialAdmissionFixture { + t.Helper() + ctx := t.Context() + db := newTestDB(t) + organizationID := "org-admission-" + uuid.NewString() + ownerUserID := "owner-" + uuid.NewString() + authorizerUserID := "authorizer-" + uuid.NewString() + projectID := "project-" + uuid.NewString() + seedOrganization(t, ctx, db, organizationID) + + for _, userID := range []string{ownerUserID, authorizerUserID} { + _, err := usersrepo.New(db).UpsertUser(ctx, usersrepo.UpsertUserParams{ + ID: userID, Email: userID + "@example.com", DisplayName: userID, PhotoUrl: conv.PtrToPGText(nil), Admin: false, + }) + require.NoError(t, err) + _, err = orgrepo.New(db).UpsertOrganizationUserRelationship(ctx, orgrepo.UpsertOrganizationUserRelationshipParams{ + OrganizationID: organizationID, UserID: conv.ToPGText(userID), + }) + require.NoError(t, err) + } + + agent, err := agentsrepo.New(db).CreateAgent(ctx, agentsrepo.CreateAgentParams{ + OrganizationID: organizationID, OwnerUserID: ownerUserID, Name: "Credential admission agent", + }) + require.NoError(t, err) + agentPrincipal := urn.NewPrincipal(urn.PrincipalTypeAgent, agent.ID.String()) + ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, ownerUserID) + seedGrant(t, ctx, db, organizationID, agentPrincipal, ScopeProjectRead, projectID) + seedGrant(t, ctx, db, organizationID, ownerPrincipal, ScopeProjectRead, projectID) + + policy, err := NewDelegatedPolicyV1([]Grant{NewGrant(ScopeProjectRead, projectID)}) + require.NoError(t, err) + rawPolicy, err := EncodeDelegatedPolicy(CurrentDelegatedPolicyVersion, policy) + require.NoError(t, err) + requestContext := contextvalues.WithPrincipalCredentialAuthorization(ctx, &contextvalues.AuthContext{ + ActiveOrganizationID: organizationID, + }, agentPrincipal, contextvalues.PrincipalCredential{ + AuthorizerUserID: authorizerUserID, + DelegatedGrants: rawPolicy, + DelegatedGrantsVersion: int32(CurrentDelegatedPolicyVersion), + }) + + return credentialAdmissionFixture{ + db: db, engine: NewEngine(testenv.NewLogger(t), db, staticChallengeLogging(false), workos.NewStubClient()), requestContext: requestContext, + organizationID: organizationID, ownerUserID: ownerUserID, authorizerUserID: authorizerUserID, agentID: agent.ID, projectID: projectID, + } +} + +func requireUnauthorized(t *testing.T, err error) { + t.Helper() + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeUnauthorized, oopsErr.Code) +} diff --git a/server/internal/authz/decision_telemetry.go b/server/internal/authz/decision_telemetry.go index 9304c96445c..23d2e44de4b 100644 --- a/server/internal/authz/decision_telemetry.go +++ b/server/internal/authz/decision_telemetry.go @@ -42,6 +42,12 @@ func RecordAuthorizationDecision(ctx context.Context, operation repo.Operation, if authCtx.APIKeyID != "" { attrs = append(attrs, attribute.String("gram.authorization.api_key_id", authCtx.APIKeyID)) } + if authorizerUserID, ownerUserID, ok := contextvalues.PrincipalCredentialProvenance(ctx); ok { + attrs = append(attrs, + attribute.String("gram.authorization.authorizer_user_id", authorizerUserID), + attribute.String("gram.authorization.owner_user_id", ownerUserID), + ) + } } if clientID, ok := contextvalues.GetOAuthClientID(ctx); ok { attrs = append(attrs, attribute.String("gram.authorization.oauth_client_id", clientID)) diff --git a/server/internal/authz/decision_telemetry_test.go b/server/internal/authz/decision_telemetry_test.go index 38b4eb3cb37..d7e01e02471 100644 --- a/server/internal/authz/decision_telemetry_test.go +++ b/server/internal/authz/decision_telemetry_test.go @@ -29,7 +29,8 @@ func TestRecordAuthorizationDecisionEmitsBoundedAttribution(t *testing.T) { APIKeyID: "key_123", APIKeyName: "must-not-appear", Email: &email, - }, agent) + }, agent, contextvalues.PrincipalCredential{AuthorizerUserID: "user_authorizer"}) + ctx = contextvalues.WithPrincipalCredentialOwner(ctx, "user_owner") ctx, span := provider.Tracer("test").Start(ctx, "request") RecordAuthorizationDecision(ctx, repo.OperationRequire, repo.OutcomeDeny, repo.ReasonScopeUnsatisfied) span.End() @@ -47,6 +48,8 @@ func TestRecordAuthorizationDecisionEmitsBoundedAttribution(t *testing.T) { require.Equal(t, "agent", attrs["gram.authorization.actor.type"]) require.Equal(t, agent.ID, attrs["gram.authorization.actor.id"]) require.Equal(t, "key_123", attrs["gram.authorization.api_key_id"]) + require.Equal(t, "user_authorizer", attrs["gram.authorization.authorizer_user_id"]) + require.Equal(t, "user_owner", attrs["gram.authorization.owner_user_id"]) require.NotContains(t, attrs, "gram.authorization.session_id") require.NotContains(t, attrs, "gram.authorization.oauth_client_id") require.NotContains(t, attrs, "gram.authorization.actor.name") diff --git a/server/internal/authz/delegated_policy.go b/server/internal/authz/delegated_policy.go new file mode 100644 index 00000000000..8967b8925e1 --- /dev/null +++ b/server/internal/authz/delegated_policy.go @@ -0,0 +1,390 @@ +package authz + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "slices" +) + +// DelegatedPolicyVersion identifies the persisted delegated-policy format and +// the agent-runtime scope registry used to validate it. +type DelegatedPolicyVersion int32 + +const ( + DelegatedPolicyVersion1 DelegatedPolicyVersion = 1 + + CurrentDelegatedPolicyVersion = DelegatedPolicyVersion1 +) + +// ErrInvalidDelegatedPolicy marks a policy that must fail closed. +var ErrInvalidDelegatedPolicy = errors.New("invalid delegated policy") + +// DelegatedPolicyGrant is one canonical allow grant. The absence of an effect +// field is intentional: delegated policies cannot contain deny-effect rows. +type DelegatedPolicyGrant struct { + Scope Scope `json:"scope"` + Selector Selector `json:"selector"` +} + +// DelegatedPolicy is the versioned credential-policy envelope. Requested is +// retained for display and audit; authorization uses only Effective. +type DelegatedPolicy struct { + Requested []DelegatedPolicyGrant `json:"requested"` + Effective []DelegatedPolicyGrant `json:"effective"` + + runtimeGrants []Grant +} + +// NewDelegatedPolicyV1 constructs the current canonical policy from requested +// grants and records their explicit implication closure in Effective. +func NewDelegatedPolicyV1(requested []Grant) (DelegatedPolicy, error) { + return NewDelegatedPolicy(DelegatedPolicyVersion1, requested) +} + +// NewDelegatedPolicy constructs a canonical policy suitable for persistence by +// a future credential issuer. Issuers may only use active agent-runtime scopes. +func NewDelegatedPolicy(version DelegatedPolicyVersion, requested []Grant) (DelegatedPolicy, error) { + if err := validateDelegatedPolicyVersion(version); err != nil { + return DelegatedPolicy{}, err + } + + wireRequested := make([]DelegatedPolicyGrant, 0, len(requested)) + seen := make(map[string]struct{}, len(requested)) + for _, grant := range requested { + if err := ValidateAgentRuntimeScope(AgentRuntimeScopeRegistryVersion(version), grant.Scope); err != nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("validate requested scope %q: %v", grant.Scope, err) + } + if err := ValidateSelector(grant.Scope, grant.Selector); err != nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("validate requested selector for %q: %v", grant.Scope, err) + } + + wireGrant := DelegatedPolicyGrant{Scope: grant.Scope, Selector: cloneSelector(grant.Selector)} + key, err := delegatedPolicyGrantKey(wireGrant) + if err != nil { + return DelegatedPolicy{}, err + } + if _, ok := seen[key]; ok { + return DelegatedPolicy{}, invalidDelegatedPolicy("duplicate requested grant") + } + seen[key] = struct{}{} + wireRequested = append(wireRequested, wireGrant) + } + sortDelegatedPolicyGrants(wireRequested) + + effective, err := delegatedPolicyClosure(wireRequested) + if err != nil { + return DelegatedPolicy{}, err + } + policy := DelegatedPolicy{ + Requested: wireRequested, + Effective: effective, + runtimeGrants: runtimeGrants(version, effective), + } + return policy, nil +} + +// EncodeDelegatedPolicy returns canonical JSON for a policy produced by the +// constructor. Mutated, stale, or otherwise noncanonical policies are rejected. +func EncodeDelegatedPolicy(version DelegatedPolicyVersion, policy DelegatedPolicy) ([]byte, error) { + validated, err := validateDecodedDelegatedPolicy(version, policy) + if err != nil { + return nil, err + } + encoded, err := json.Marshal(struct { + Requested []DelegatedPolicyGrant `json:"requested"` + Effective []DelegatedPolicyGrant `json:"effective"` + }{Requested: validated.Requested, Effective: validated.Effective}) + if err != nil { + return nil, invalidDelegatedPolicy("encode: %v", err) + } + return encoded, nil +} + +// DecodeDelegatedPolicy strictly decodes policy JSON loaded with its credential +// row. Unknown and retired scopes are retained for compatibility but omitted +// from RuntimeGrants, so each such entry fails closed without disabling valid +// entries. All other malformed or noncanonical profiles are rejected. +func DecodeDelegatedPolicy(version DelegatedPolicyVersion, raw []byte) (DelegatedPolicy, error) { + if err := validateDelegatedPolicyVersion(version); err != nil { + return DelegatedPolicy{}, err + } + if err := rejectDuplicateJSONKeys(raw); err != nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("decode: %v", err) + } + + var wire struct { + Requested []DelegatedPolicyGrant `json:"requested"` + Effective []DelegatedPolicyGrant `json:"effective"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&wire); err != nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("decode: %v", err) + } + if err := ensureJSONEOF(decoder); err != nil { + return DelegatedPolicy{}, err + } + if wire.Requested == nil || wire.Effective == nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("requested and effective must be arrays") + } + + return validateDecodedDelegatedPolicy(version, DelegatedPolicy{ + Requested: wire.Requested, + Effective: wire.Effective, + runtimeGrants: nil, + }) +} + +// RuntimeGrants returns a defensive copy of the stored effective policy entries +// that are active and agent-runtime-safe for the policy version. +func (p DelegatedPolicy) RuntimeGrants() []Grant { + grants := make([]Grant, len(p.runtimeGrants)) + for i, grant := range p.runtimeGrants { + grants[i] = Grant{PrincipalUrn: "", Scope: grant.Scope, Selector: cloneSelector(grant.Selector)} + } + return grants +} + +func validateDecodedDelegatedPolicy(version DelegatedPolicyVersion, policy DelegatedPolicy) (DelegatedPolicy, error) { + if err := validateDelegatedPolicyVersion(version); err != nil { + return DelegatedPolicy{}, err + } + if policy.Requested == nil || policy.Effective == nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("requested and effective must be arrays") + } + if err := validateCanonicalDelegatedPolicyGrants(version, policy.Requested, "requested"); err != nil { + return DelegatedPolicy{}, err + } + if err := validateCanonicalDelegatedPolicyGrants(version, policy.Effective, "effective"); err != nil { + return DelegatedPolicy{}, err + } + + expected, err := delegatedPolicyClosure(policy.Requested) + if err != nil { + return DelegatedPolicy{}, err + } + if !equalDelegatedPolicyGrants(expected, policy.Effective) { + return DelegatedPolicy{}, invalidDelegatedPolicy("effective grants are not the requested implication closure") + } + + validated := DelegatedPolicy{ + Requested: cloneDelegatedPolicyGrants(policy.Requested), + Effective: cloneDelegatedPolicyGrants(policy.Effective), + runtimeGrants: runtimeGrants(version, policy.Effective), + } + return validated, nil +} + +func validateCanonicalDelegatedPolicyGrants(version DelegatedPolicyVersion, grants []DelegatedPolicyGrant, field string) error { + previous := "" + for i, grant := range grants { + if grant.Scope == "" { + return invalidDelegatedPolicy("%s grant %d has an empty scope", field, i) + } + if err := validateStoredDelegatedPolicyGrant(version, grant); err != nil { + return invalidDelegatedPolicy("validate %s grant %d: %v", field, i, err) + } + key, err := delegatedPolicyGrantKey(grant) + if err != nil { + return err + } + if i > 0 && key <= previous { + return invalidDelegatedPolicy("%s grants are unordered or duplicated", field) + } + previous = key + } + return nil +} + +func validateStoredDelegatedPolicyGrant(version DelegatedPolicyVersion, grant DelegatedPolicyGrant) error { + if grant.Selector == nil { + return errors.New("selector must be an object") + } + if _, ok := grant.Selector[SelectorKeyResourceKind]; !ok { + return errors.New("selector must include resource_kind") + } + if _, ok := grant.Selector[SelectorKeyResourceID]; !ok { + return errors.New("selector must include resource_id") + } + + definition, known := scopeDefinitions[grant.Scope] + if !known || definition.lifecycle == ScopeLifecycleRetired { + return nil + } + if definition.agentRuntimeSafeSince == 0 || definition.agentRuntimeSafeSince > AgentRuntimeScopeRegistryVersion(version) { + return fmt.Errorf("scope %q is not agent-runtime-safe", grant.Scope) + } + return ValidateSelector(grant.Scope, grant.Selector) +} + +func delegatedPolicyClosure(requested []DelegatedPolicyGrant) ([]DelegatedPolicyGrant, error) { + effective := make([]DelegatedPolicyGrant, 0, len(requested)) + seen := make(map[string]struct{}, len(requested)) + for _, grant := range requested { + for _, scope := range AgentRuntimeScopeImplicationClosure(grant.Scope) { + implied := DelegatedPolicyGrant{Scope: scope, Selector: cloneSelector(grant.Selector)} + key, err := delegatedPolicyGrantKey(implied) + if err != nil { + return nil, err + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + effective = append(effective, implied) + } + } + sortDelegatedPolicyGrants(effective) + return effective, nil +} + +func runtimeGrants(version DelegatedPolicyVersion, effective []DelegatedPolicyGrant) []Grant { + grants := make([]Grant, 0, len(effective)) + for _, grant := range effective { + definition, known := scopeDefinitions[grant.Scope] + if !known || definition.lifecycle != ScopeLifecycleActive || definition.agentRuntimeSafeSince == 0 || definition.agentRuntimeSafeSince > AgentRuntimeScopeRegistryVersion(version) { + continue + } + grants = append(grants, Grant{PrincipalUrn: "", Scope: grant.Scope, Selector: cloneSelector(grant.Selector)}) + } + return grants +} + +func sortDelegatedPolicyGrants(grants []DelegatedPolicyGrant) { + slices.SortFunc(grants, func(a, b DelegatedPolicyGrant) int { + aKey, _ := delegatedPolicyGrantKey(a) + bKey, _ := delegatedPolicyGrantKey(b) + if aKey < bKey { + return -1 + } + if aKey > bKey { + return 1 + } + return 0 + }) +} + +func delegatedPolicyGrantKey(grant DelegatedPolicyGrant) (string, error) { + selector, err := json.Marshal(grant.Selector) + if err != nil { + return "", invalidDelegatedPolicy("marshal selector: %v", err) + } + return string(grant.Scope) + "\x00" + string(selector), nil +} + +func equalDelegatedPolicyGrants(a, b []DelegatedPolicyGrant) bool { + if len(a) != len(b) { + return false + } + for i := range a { + aKey, err := delegatedPolicyGrantKey(a[i]) + if err != nil { + return false + } + bKey, err := delegatedPolicyGrantKey(b[i]) + if err != nil || aKey != bKey { + return false + } + } + return true +} + +func cloneDelegatedPolicyGrants(grants []DelegatedPolicyGrant) []DelegatedPolicyGrant { + cloned := make([]DelegatedPolicyGrant, len(grants)) + for i, grant := range grants { + cloned[i] = DelegatedPolicyGrant{Scope: grant.Scope, Selector: cloneSelector(grant.Selector)} + } + return cloned +} + +func cloneSelector(selector Selector) Selector { + if selector == nil { + return nil + } + cloned := make(Selector, len(selector)) + maps.Copy(cloned, selector) + return cloned +} + +func rejectDuplicateJSONKeys(raw []byte) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + var visit func() error + visit = func() error { + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("read JSON token: %w", err) + } + delim, ok := token.(json.Delim) + if !ok { + return nil + } + switch delim { + case '{': + seen := map[string]struct{}{} + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return fmt.Errorf("read JSON object key: %w", err) + } + key, ok := keyToken.(string) + if !ok { + return errors.New("JSON object key is not a string") + } + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("duplicate JSON object key %q", key) + } + seen[key] = struct{}{} + if err := visit(); err != nil { + return err + } + } + case '[': + for decoder.More() { + if err := visit(); err != nil { + return err + } + } + default: + return fmt.Errorf("unexpected JSON delimiter %q", delim) + } + closing, err := decoder.Token() + if err != nil { + return fmt.Errorf("read JSON closing delimiter: %w", err) + } + expected := json.Delim('}') + if delim == '[' { + expected = ']' + } + if closing != expected { + return fmt.Errorf("unexpected JSON closing delimiter %q", closing) + } + return nil + } + return visit() +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return invalidDelegatedPolicy("decode: trailing JSON value") + } + return invalidDelegatedPolicy("decode trailing data: %v", err) + } + return nil +} + +func validateDelegatedPolicyVersion(version DelegatedPolicyVersion) error { + if version != DelegatedPolicyVersion1 { + return invalidDelegatedPolicy("unsupported version %d", version) + } + return nil +} + +func invalidDelegatedPolicy(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrInvalidDelegatedPolicy, fmt.Sprintf(format, args...)) +} diff --git a/server/internal/authz/delegated_policy_test.go b/server/internal/authz/delegated_policy_test.go new file mode 100644 index 00000000000..89754dbb362 --- /dev/null +++ b/server/internal/authz/delegated_policy_test.go @@ -0,0 +1,148 @@ +package authz + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewDelegatedPolicyV1CanonicalizesAndClosesImplications(t *testing.T) { + t.Parallel() + + policy, err := NewDelegatedPolicyV1([]Grant{ + NewGrant(ScopeProjectRead, "project-one"), + {Scope: ScopeMCPWrite, Selector: Selector{ + SelectorKeyResourceKind: ResourceKindMCP, + SelectorKeyResourceID: "server-one", + SelectorKeyTool: "tool-one", + }}, + }) + require.NoError(t, err) + + require.Equal(t, []Scope{ScopeMCPWrite, ScopeProjectRead}, delegatedPolicyScopes(policy.Requested)) + require.Equal(t, []Scope{ScopeMCPConnect, ScopeMCPRead, ScopeMCPWrite, ScopeProjectRead}, delegatedPolicyScopes(policy.Effective)) + + runtime := policy.RuntimeGrants() + require.Len(t, runtime, 4) + require.True(t, GrantsSatisfy(runtime, MCPToolCallCheck("server-one", MCPToolCallDimensions{Tool: "tool-one"}))) + require.True(t, GrantsSatisfy(runtime, Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) + + encoded, err := EncodeDelegatedPolicy(DelegatedPolicyVersion1, policy) + require.NoError(t, err) + decoded, err := DecodeDelegatedPolicy(DelegatedPolicyVersion1, encoded) + require.NoError(t, err) + require.Equal(t, policy.Requested, decoded.Requested) + require.Equal(t, policy.Effective, decoded.Effective) +} + +func TestDecodeDelegatedPolicyUsesEffectivePolicy(t *testing.T) { + t.Parallel() + + selector := NewSelector(ScopeProjectRead, "project-one") + raw := mustPolicyJSON(t, DelegatedPolicy{ + Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: selector}}, + Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: selector}}, + }) + policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion1, raw) + require.NoError(t, err) + + policy.Requested[0].Selector[SelectorKeyResourceID] = "mutated-request" + runtime := policy.RuntimeGrants() + require.True(t, GrantsSatisfy(runtime, Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) + require.False(t, GrantsSatisfy(runtime, Check{Scope: ScopeProjectRead, ResourceID: "mutated-request"})) + + runtime[0].Selector[SelectorKeyResourceID] = "mutated-runtime" + require.True(t, GrantsSatisfy(policy.RuntimeGrants(), Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) +} + +func TestDecodeDelegatedPolicySkipsUnknownAndRetiredEntries(t *testing.T) { + t.Parallel() + + entries := []DelegatedPolicyGrant{ + {Scope: scopeMCPApprovalReadTombstone, Selector: Selector{SelectorKeyResourceKind: "mcp_approval", SelectorKeyResourceID: "approval-one"}}, + {Scope: ScopeProjectRead, Selector: NewSelector(ScopeProjectRead, "project-one")}, + {Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown", SelectorKeyResourceID: "unknown-one"}}, + } + raw := mustPolicyJSON(t, DelegatedPolicy{Requested: entries, Effective: entries}) + + policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion1, raw) + require.NoError(t, err) + require.Len(t, policy.Requested, 3) + require.Equal(t, []Scope{ScopeProjectRead}, grantScopes(policy.RuntimeGrants())) + require.True(t, GrantsSatisfy(policy.RuntimeGrants(), Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) +} + +func TestDecodeDelegatedPolicyRejectsInvalidProfiles(t *testing.T) { + t.Parallel() + + project := DelegatedPolicyGrant{Scope: ScopeProjectRead, Selector: NewSelector(ScopeProjectRead, "project-one")} + mcp := DelegatedPolicyGrant{Scope: ScopeMCPRead, Selector: NewSelector(ScopeMCPRead, "server-one")} + + tests := map[string]struct { + version DelegatedPolicyVersion + raw []byte + }{ + "unsupported version": {version: 2, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{}, Effective: []DelegatedPolicyGrant{}})}, + "unknown envelope field": {version: 1, raw: []byte(`{"requested":[],"effective":[],"effect":"allow"}`)}, + "trailing value": {version: 1, raw: []byte(`{"requested":[],"effective":[]} {}`)}, + "missing requested": {version: 1, raw: []byte(`{"effective":[]}`)}, + "null effective": {version: 1, raw: []byte(`{"requested":[],"effective":null}`)}, + "unknown grant field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one"},"effect":"allow"}],"effective":[]}`)}, + "duplicate envelope field": {version: 1, raw: []byte(`{"requested":[],"requested":[],"effective":[]}`)}, + "duplicate grant field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","scope":"mcp:read","selector":{"resource_kind":"project","resource_id":"project-one"}}],"effective":[]}`)}, + "duplicate selector field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one","resource_id":"project-two"}}],"effective":[]}`)}, + "unordered requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, mcp}, Effective: []DelegatedPolicyGrant{mcp, project}})}, + "duplicate requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, project}, Effective: []DelegatedPolicyGrant{project}})}, + "duplicate effective": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project}, Effective: []DelegatedPolicyGrant{project, project}})}, + "noncanonical closure": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}})}, + "unsafe active scope": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}})}, + "malformed known selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}})}, + "malformed unknown selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}, Effective: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}})}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := DecodeDelegatedPolicy(test.version, test.raw) + require.ErrorIs(t, err, ErrInvalidDelegatedPolicy) + }) + } +} + +func TestNewDelegatedPolicyRejectsInvalidIssuerInput(t *testing.T) { + t.Parallel() + + _, err := NewDelegatedPolicyV1([]Grant{NewGrant(ScopeAgentWrite, "agent-one")}) + require.ErrorIs(t, err, ErrInvalidDelegatedPolicy) + + grant := NewGrant(ScopeProjectRead, "project-one") + _, err = NewDelegatedPolicyV1([]Grant{grant, grant}) + require.ErrorIs(t, err, ErrInvalidDelegatedPolicy) +} + +func delegatedPolicyScopes(grants []DelegatedPolicyGrant) []Scope { + scopes := make([]Scope, len(grants)) + for i, grant := range grants { + scopes[i] = grant.Scope + } + return scopes +} + +func grantScopes(grants []Grant) []Scope { + scopes := make([]Scope, len(grants)) + for i, grant := range grants { + scopes[i] = grant.Scope + } + return scopes +} + +func mustPolicyJSON(t *testing.T, policy DelegatedPolicy) []byte { + t.Helper() + raw, err := json.Marshal(struct { + Requested []DelegatedPolicyGrant `json:"requested"` + Effective []DelegatedPolicyGrant `json:"effective"` + }{Requested: policy.Requested, Effective: policy.Effective}) + require.NoError(t, err) + return raw +} diff --git a/server/internal/authz/engine.go b/server/internal/authz/engine.go index 1438404a3b2..e5aafcd429f 100644 --- a/server/internal/authz/engine.go +++ b/server/internal/authz/engine.go @@ -91,14 +91,17 @@ func (e *Engine) PrepareContext(ctx context.Context) (context.Context, error) { case contextvalues.APIKeyAuthorizationModeLegacy: return ctx, nil case contextvalues.APIKeyAuthorizationModePrincipal: - // Principal-backed admission owns grant loading. Until it has attached - // grants, an empty prepared set makes every check fail closed. - return GrantsToContext(ctx, nil), nil + return e.AdmitPrincipalCredential(ctx) } } if authCtx.APIKeyID != "" { return ctx, oops.C(oops.CodeUnauthorized) } + // Future principal-backed transports, including agent MCP sessions, attach + // the same immutable credential profile and reuse this admission path. + if _, hasCredential := contextvalues.PrincipalCredentialAuthorization(ctx); hasCredential { + return e.AdmitPrincipalCredential(ctx) + } if _, ok := GrantsFromContext(ctx); ok { return ctx, nil @@ -178,18 +181,28 @@ func (e *Engine) Require(ctx context.Context, checks ...Check) error { return e.mapError(ctx, ErrNoChecks) } - grants, ok := GrantsFromContext(ctx) + authorization, ok := grantAuthorizationFromContext(ctx) if !ok { return e.mapError(ctx, ErrMissingGrants) } - return e.EvaluateLoadedGrants(ctx, grants, checks...) + return e.evaluateRequired(ctx, authorization, checks...) } // EvaluateLoadedGrants evaluates explicit grants against checks without // consulting ShouldEnforce or reading grants from context. Request handlers // should use Require so normal request enforcement semantics apply. func (e *Engine) EvaluateLoadedGrants(ctx context.Context, grants []Grant, checks ...Check) error { + if _, ok := contextvalues.PrincipalCredentialAuthorization(ctx); ok { + return oops.C(oops.CodeForbidden) + } + if mode, ok := contextvalues.APIKeyAuthorization(ctx); ok && mode == contextvalues.APIKeyAuthorizationModePrincipal { + return oops.C(oops.CodeForbidden) + } + return e.evaluateRequired(ctx, loadedGrantAuthorization(grants), checks...) +} + +func (e *Engine) evaluateRequired(ctx context.Context, authorization grantAuthorization, checks ...Check) error { if len(checks) == 0 { return e.mapError(ctx, ErrNoChecks) } @@ -204,14 +217,14 @@ func (e *Engine) EvaluateLoadedGrants(ctx context.Context, grants []Grant, check Checks: checks, Focus: &check, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) return e.mapError(ctx, err) } - evaluation, err := evaluateGrantCheck(grants, check) + evaluation, err := authorization.evaluate(check) if err != nil { challengeLogger{ Operation: authzrepo.OperationRequire, @@ -220,7 +233,7 @@ func (e *Engine) EvaluateLoadedGrants(ctx context.Context, grants []Grant, check Checks: checks, Focus: &check, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -231,7 +244,7 @@ func (e *Engine) EvaluateLoadedGrants(ctx context.Context, grants []Grant, check switch { case evaluation.Denied: reason = authzrepo.ReasonDenyGrant - case len(grants) == 0: + case authorization.grantCount() == 0: reason = authzrepo.ReasonNoGrants } challengeLogger{ @@ -241,7 +254,7 @@ func (e *Engine) EvaluateLoadedGrants(ctx context.Context, grants []Grant, check Checks: checks, Focus: &check, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -257,7 +270,7 @@ func (e *Engine) EvaluateLoadedGrants(ctx context.Context, grants []Grant, check Checks: checks, Focus: &checks[0], Matches: matches, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -276,7 +289,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { return e.mapError(ctx, ErrNoChecks) } - grants, ok := GrantsFromContext(ctx) + authorization, ok := grantAuthorizationFromContext(ctx) if !ok { return e.mapError(ctx, ErrMissingGrants) } @@ -290,7 +303,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { Checks: checks, Focus: &check, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -300,7 +313,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { anyDenied := false for _, check := range checks { - evaluation, err := evaluateGrantCheck(grants, check) + evaluation, err := authorization.evaluate(check) if err != nil { challengeLogger{ Operation: authzrepo.OperationRequireAny, @@ -309,7 +322,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { Checks: checks, Focus: &check, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -327,7 +340,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { Checks: checks, Focus: &check, Matches: []grantMatch{{Grant: *evaluation.Grant, ViaCheck: *evaluation.Check}}, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -339,7 +352,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { switch { case anyDenied: reason = authzrepo.ReasonDenyGrant - case len(grants) == 0: + case authorization.grantCount() == 0: reason = authzrepo.ReasonNoGrants } challengeLogger{ @@ -349,7 +362,7 @@ func (e *Engine) RequireAny(ctx context.Context, checks ...Check) error { Checks: checks, Focus: &checks[0], Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small FilterCandidateCount: 0, FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) @@ -381,7 +394,7 @@ func (e *Engine) Evaluate(ctx context.Context, checks ...Check) (bool, error) { return false, e.mapError(ctx, ErrNoChecks) } - grants, ok := GrantsFromContext(ctx) + authorization, ok := grantAuthorizationFromContext(ctx) if !ok { return false, e.mapError(ctx, ErrMissingGrants) } @@ -390,7 +403,7 @@ func (e *Engine) Evaluate(ctx context.Context, checks ...Check) (bool, error) { if err := validateInput(check); err != nil { return false, e.mapError(ctx, err) } - evaluation, err := evaluateGrantCheck(grants, check) + evaluation, err := authorization.evaluate(check) if err != nil { return false, e.mapError(ctx, err) } @@ -418,7 +431,7 @@ func (e *Engine) Filter(ctx context.Context, checks []Check) ([]string, error) { return ids, nil } - grants, ok := GrantsFromContext(ctx) + authorization, ok := grantAuthorizationFromContext(ctx) if !ok { return nil, e.mapError(ctx, ErrMissingGrants) } @@ -436,14 +449,14 @@ func (e *Engine) Filter(ctx context.Context, checks []Check) ([]string, error) { Checks: checks, Focus: &focus, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small - FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small + FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) return nil, e.mapError(ctx, err) } - evaluation, err := evaluateGrantCheck(grants, c) + evaluation, err := authorization.evaluate(c) if err != nil { focus := c challengeLogger{ @@ -453,8 +466,8 @@ func (e *Engine) Filter(ctx context.Context, checks []Check) ([]string, error) { Checks: checks, Focus: &focus, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small - FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small + FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) return nil, e.mapError(ctx, err) @@ -477,7 +490,7 @@ func (e *Engine) Filter(ctx context.Context, checks []Check) ([]string, error) { reason = authzrepo.ReasonGrantMatched case anyDenied: reason = authzrepo.ReasonDenyGrant - case len(grants) == 0: + case authorization.grantCount() == 0: reason = authzrepo.ReasonNoGrants } challengeLogger{ @@ -487,9 +500,9 @@ func (e *Engine) Filter(ctx context.Context, checks []Check) ([]string, error) { Checks: checks, Focus: nil, Matches: matches, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small - FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small - FilterAllowedCount: uint32(len(allowed)), //nolint:gosec // allowed count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small + FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small + FilterAllowedCount: uint32(len(allowed)), //nolint:gosec // allowed count is small }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) } @@ -522,7 +535,7 @@ func (e *Engine) FindMatched(ctx context.Context, checks []Check) ([]bool, error return out, nil } - grants, ok := GrantsFromContext(ctx) + authorization, ok := grantAuthorizationFromContext(ctx) if !ok { return nil, e.mapError(ctx, ErrMissingGrants) } @@ -541,14 +554,14 @@ func (e *Engine) FindMatched(ctx context.Context, checks []Check) ([]bool, error Checks: checks, Focus: &focus, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small - FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small + FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) return nil, e.mapError(ctx, err) } - evaluation, err := evaluateGrantCheck(grants, c) + evaluation, err := authorization.evaluate(c) if err != nil { focus := c challengeLogger{ @@ -558,8 +571,8 @@ func (e *Engine) FindMatched(ctx context.Context, checks []Check) ([]bool, error Checks: checks, Focus: &focus, Matches: nil, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small - FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small + FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small FilterAllowedCount: 0, }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) return nil, e.mapError(ctx, err) @@ -583,7 +596,7 @@ func (e *Engine) FindMatched(ctx context.Context, checks []Check) ([]bool, error reason = authzrepo.ReasonGrantMatched case anyDenied: reason = authzrepo.ReasonDenyGrant - case len(grants) == 0: + case authorization.grantCount() == 0: reason = authzrepo.ReasonNoGrants } challengeLogger{ @@ -593,8 +606,8 @@ func (e *Engine) FindMatched(ctx context.Context, checks []Check) ([]bool, error Checks: checks, Focus: nil, Matches: matches, - EvaluatedGrantCount: uint32(len(grants)), //nolint:gosec // grant count is small - FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small + EvaluatedGrantCount: uint32(authorization.grantCount()), //nolint:gosec // grant count is small + FilterCandidateCount: uint32(len(checks)), //nolint:gosec // candidate count is small FilterAllowedCount: uint32(allowedCount), }.Log(ctx, e.db, e.logger, e.challengeLoggingEnabled) } @@ -608,6 +621,10 @@ func (e *Engine) ShouldEnforce(ctx context.Context) (bool, error) { return false, oops.C(oops.CodeUnauthorized) } + if _, principalCredential := contextvalues.PrincipalCredentialAuthorization(ctx); principalCredential { + return true, nil + } + if mode, hasMode := contextvalues.APIKeyAuthorization(ctx); hasMode { if authCtx.APIKeyID == "" { return false, oops.C(oops.CodeUnauthorized) diff --git a/server/internal/authz/engine_test.go b/server/internal/authz/engine_test.go index 48dd2a07a46..9075277949e 100644 --- a/server/internal/authz/engine_test.go +++ b/server/internal/authz/engine_test.go @@ -446,24 +446,134 @@ func TestEngineRequire_APIKeyAuthorizationModeIsExplicit(t *testing.T) { require.NoError(t, engine.Require(legacy, check)) agent := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") - principalBacked := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), authCtx, agent) + principalBacked := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), authCtx, agent, contextvalues.PrincipalCredential{}) enforce, err := engine.ShouldEnforce(principalBacked) require.NoError(t, err) require.True(t, enforce) + err = engine.EvaluateLoadedGrants(principalBacked, []Grant{NewGrant(ScopeProjectRead, "proj_123")}, check) + var explicitErr *oops.ShareableError + require.ErrorAs(t, err, &explicitErr) + require.Equal(t, oops.CodeForbidden, explicitErr.Code) + + unclassifiedPrincipalAPIKey := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), authCtx, agent, contextvalues.PrincipalCredential{}) + _, err = engine.PrepareContext(unclassifiedPrincipalAPIKey) + var unclassifiedPrincipalErr *oops.ShareableError + require.ErrorAs(t, err, &unclassifiedPrincipalErr) + require.Equal(t, oops.CodeUnauthorized, unclassifiedPrincipalErr.Code) + + genericPrincipal := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &contextvalues.AuthContext{ + ActiveOrganizationID: "org_123", + }, agent, contextvalues.PrincipalCredential{}) + enforce, err = engine.ShouldEnforce(genericPrincipal) + require.NoError(t, err) + require.True(t, enforce, "principal credentials enforce even without API-key mode or a session ID") // Generic preloaded grants cannot bypass principal-backed admission. principalBacked = GrantsToContext(principalBacked, []Grant{NewGrant(ScopeProjectRead, "proj_123")}) - principalBacked, err = engine.PrepareContext(principalBacked) - require.NoError(t, err) - prepared, ok := GrantsFromContext(principalBacked) - require.True(t, ok) - require.Empty(t, prepared) + _, err = engine.PrepareContext(principalBacked) + var admissionErr *oops.ShareableError + require.ErrorAs(t, err, &admissionErr) + require.Equal(t, oops.CodeUnauthorized, admissionErr.Code) err = engine.Require(principalBacked, check) require.Error(t, err) var principalErr *oops.ShareableError require.ErrorAs(t, err, &principalErr) - require.Equal(t, oops.CodeForbidden, principalErr.Code) + require.Equal(t, oops.CodeUnexpected, principalErr.Code) + require.ErrorIs(t, err, ErrMissingGrants) +} + +func TestPrincipalCredentialPoliciesConjoinEveryCheck(t *testing.T) { + t.Parallel() + engine := NewEngine(testenv.NewLogger(t), nil, staticChallengeLogging(false), workos.NewStubClient()) + check := Check{Scope: ScopeProjectRead, ResourceID: "project-one"} + + credential := []Grant{NewGrant(ScopeProjectWrite, "project-one")} + agent := []Grant{NewGrant(ScopeProjectRead, WildcardResource)} + owner := []Grant{NewGrant(ScopeProjectWrite, "project-one")} + ctx := principalPolicyTestContext(t, credential, agent, owner) + require.NoError(t, engine.Require(ctx, check), "normal implication and wildcard evaluation applies independently to every policy") + + for name, policies := range map[string][][]Grant{ + "credential R": {nil, agent, owner}, + "agent A": {credential, nil, owner}, + "owner O": {credential, agent, nil}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + err := engine.Require(principalPolicyTestContext(t, policies[0], policies[1], policies[2]), check) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeForbidden, oopsErr.Code) + }) + } +} + +func TestPrincipalCredentialPoliciesRequireAnyUsesSameCheck(t *testing.T) { + t.Parallel() + engine := NewEngine(testenv.NewLogger(t), nil, staticChallengeLogging(false), workos.NewStubClient()) + ctx := principalPolicyTestContext(t, + []Grant{NewGrant(ScopeProjectRead, "project-one")}, + []Grant{NewGrant(ScopeProjectRead, "project-two")}, + []Grant{NewGrant(ScopeProjectRead, WildcardResource)}, + ) + + err := engine.RequireAny(ctx, + Check{Scope: ScopeProjectRead, ResourceID: "project-one"}, + Check{Scope: ScopeProjectRead, ResourceID: "project-two"}, + ) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeForbidden, oopsErr.Code) +} + +func TestPrincipalCredentialPoliciesApplyToEvaluationAndFilters(t *testing.T) { + t.Parallel() + engine := NewEngine(testenv.NewLogger(t), nil, staticChallengeLogging(false), workos.NewStubClient()) + ctx := principalPolicyTestContext(t, + []Grant{NewGrant(ScopeProjectWrite, WildcardResource)}, + []Grant{NewGrant(ScopeProjectRead, "project-one")}, + []Grant{NewGrant(ScopeProjectWrite, WildcardResource)}, + ) + checks := []Check{ + {Scope: ScopeProjectRead, ResourceID: "project-one"}, + {Scope: ScopeProjectRead, ResourceID: "project-two"}, + } + + allowed, err := engine.Evaluate(ctx, checks[0]) + require.NoError(t, err) + require.True(t, allowed) + allowed, err = engine.Evaluate(ctx, checks[1]) + require.NoError(t, err) + require.False(t, allowed) + + filtered, err := engine.Filter(ctx, checks) + require.NoError(t, err) + require.Equal(t, []string{"project-one"}, filtered) + matched, err := engine.FindMatched(ctx, checks) + require.NoError(t, err) + require.Equal(t, []bool{true, false}, matched) + + ownerExcluded := append([]Grant{NewGrant(ScopeProjectWrite, WildcardResource)}, NewGrant(ScopeProjectBlockedRead, "project-one")) + excluded := principalPolicyTestContext(t, + []Grant{NewGrant(ScopeProjectRead, "project-one")}, + []Grant{NewGrant(ScopeProjectRead, "project-one")}, + ownerExcluded, + ) + err = engine.Require(excluded, checks[0]) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeForbidden, oopsErr.Code) +} + +func principalPolicyTestContext(t *testing.T, credential, agent, owner []Grant) context.Context { + t.Helper() + actor := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + ctx := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), &contextvalues.AuthContext{ + ActiveOrganizationID: "org_123", + APIKeyID: "key_123", + }, actor, contextvalues.PrincipalCredential{}) + return principalCredentialPoliciesToContext(ctx, credential, agent, owner) } func TestEngineFilter_enforcesForNonEnterpriseAccount(t *testing.T) { diff --git a/server/internal/authz/repo/models.go b/server/internal/authz/repo/models.go index dcd691d9687..2ecc54bdfa9 100644 --- a/server/internal/authz/repo/models.go +++ b/server/internal/authz/repo/models.go @@ -41,6 +41,7 @@ const ( PrincipalTypeUser PrincipalType = "user" PrincipalTypeAPIKey PrincipalType = "api_key" PrincipalTypeAssistant PrincipalType = "assistant" + PrincipalTypeAgent PrincipalType = "agent" ) // ChallengeRow mirrors one row of the authz_challenges table. diff --git a/server/internal/authz/requested_organization.go b/server/internal/authz/requested_organization.go index 98df12c5462..f8af17cb4d6 100644 --- a/server/internal/authz/requested_organization.go +++ b/server/internal/authz/requested_organization.go @@ -12,6 +12,13 @@ import ( // requested organization's namespace before evaluating an organization scope. // It intentionally does not use grants prepared for the active organization. func (e *Engine) RequireUserOrganizationScope(ctx context.Context, organizationID, userID string, scope Scope) error { + if mode, ok := contextvalues.APIKeyAuthorization(ctx); ok && mode == contextvalues.APIKeyAuthorizationModePrincipal { + return oops.C(oops.CodeForbidden) + } + if _, ok := contextvalues.PrincipalCredentialAuthorization(ctx); ok { + return oops.C(oops.CodeForbidden) + } + enforce, err := e.ShouldEnforce(ctx) if err != nil { return err diff --git a/server/internal/contextvalues/context.go b/server/internal/contextvalues/context.go index 7103a25d16f..ffe08803b91 100644 --- a/server/internal/contextvalues/context.go +++ b/server/internal/contextvalues/context.go @@ -14,6 +14,15 @@ type contextKey string // a loaded API-key row. Credential provenance alone must never select a mode. type APIKeyAuthorizationMode uint8 +// PrincipalCredential describes the immutable authorization state loaded from +// a principal-backed credential row. The current owner is resolved separately +// during live admission and is never accepted from the credential. +type PrincipalCredential struct { + AuthorizerUserID string + DelegatedGrants []byte + DelegatedGrantsVersion int32 +} + const ( APIKeyAuthorizationModeLegacy APIKeyAuthorizationMode = iota + 1 APIKeyAuthorizationModePrincipal @@ -41,6 +50,8 @@ type AuthContext struct { SupportOrganizationID string actor urn.Principal apiKeyAuthorizationMode APIKeyAuthorizationMode + principalCredential *PrincipalCredential + principalCredentialOwner string gramSessionValidated bool supportSessionValidated bool legacySessionImpersonated bool @@ -85,13 +96,59 @@ func WithLegacyAPIKeyAuthorization(ctx context.Context, authCtx *AuthContext) co // WithPrincipalAPIKeyAuthorization records a principal-backed profile selected // from authoritative credential state. Authentication code must pass the // parsed and validated credential subject as actor. -func WithPrincipalAPIKeyAuthorization(ctx context.Context, authCtx *AuthContext, actor urn.Principal) context.Context { +func WithPrincipalAPIKeyAuthorization(ctx context.Context, authCtx *AuthContext, actor urn.Principal, credential PrincipalCredential) context.Context { + ctx = WithPrincipalCredentialAuthorization(ctx, authCtx, actor, credential) + validated, _ := GetAuthContext(ctx) + cloned := *validated + cloned.apiKeyAuthorizationMode = APIKeyAuthorizationModePrincipal + return SetAuthContext(ctx, &cloned) +} + +// WithPrincipalCredentialAuthorization records the canonical actor and +// immutable policy loaded by any principal-backed transport. Transport-specific +// wrappers retain their own direct-revocation and credential provenance state. +func WithPrincipalCredentialAuthorization(ctx context.Context, authCtx *AuthContext, actor urn.Principal, credential PrincipalCredential) context.Context { validated := *authCtx validated.actor = actor - validated.apiKeyAuthorizationMode = APIKeyAuthorizationModePrincipal + credential.DelegatedGrants = append([]byte(nil), credential.DelegatedGrants...) + validated.principalCredential = &credential return SetAuthContext(ctx, &validated) } +// PrincipalCredentialAuthorization returns immutable credential policy and +// authorizer provenance established by a trusted authentication path. +func PrincipalCredentialAuthorization(ctx context.Context) (PrincipalCredential, bool) { + authCtx, ok := GetAuthContext(ctx) + if !ok || authCtx == nil || authCtx.principalCredential == nil { + return PrincipalCredential{AuthorizerUserID: "", DelegatedGrants: nil, DelegatedGrantsVersion: 0}, false + } + credential := *authCtx.principalCredential + credential.DelegatedGrants = append([]byte(nil), credential.DelegatedGrants...) + return credential, true +} + +// WithPrincipalCredentialOwner records the current owner resolved by live +// admission. It does not change the canonical actor or immutable authorizer. +func WithPrincipalCredentialOwner(ctx context.Context, ownerUserID string) context.Context { + authCtx, ok := GetAuthContext(ctx) + if !ok || authCtx == nil || authCtx.principalCredential == nil { + return ctx + } + validated := *authCtx + validated.principalCredentialOwner = ownerUserID + return SetAuthContext(ctx, &validated) +} + +// PrincipalCredentialProvenance returns trusted immutable-authorizer and +// current-owner attribution after successful live admission. +func PrincipalCredentialProvenance(ctx context.Context) (authorizerUserID, ownerUserID string, ok bool) { + authCtx, found := GetAuthContext(ctx) + if !found || authCtx == nil || authCtx.principalCredential == nil || authCtx.principalCredentialOwner == "" { + return "", "", false + } + return authCtx.principalCredential.AuthorizerUserID, authCtx.principalCredentialOwner, true +} + // AuthenticatedActor returns the canonical actor established by a trusted // authentication path. Public AuthContext fields are never used as fallbacks. func AuthenticatedActor(ctx context.Context) (urn.Principal, bool) { diff --git a/server/internal/contextvalues/context_test.go b/server/internal/contextvalues/context_test.go index 5f7e7de6ac8..a5709d6d470 100644 --- a/server/internal/contextvalues/context_test.go +++ b/server/internal/contextvalues/context_test.go @@ -79,15 +79,38 @@ func TestAuthenticatedActorAndCredentialProvenanceAreIndependent(t *testing.T) { require.Equal(t, "key_123", legacyAuth.APIKeyID) agent := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + policy := []byte(`{"requested":[],"effective":[]}`) principalBacked := WithPrincipalAPIKeyAuthorization(t.Context(), &AuthContext{ UserID: "user_authorizer", APIKeyID: "key_agent", - }, agent) + }, agent, PrincipalCredential{ + AuthorizerUserID: "user_authorizer", + DelegatedGrants: policy, + DelegatedGrantsVersion: 1, + }) + policy[0] = 'x' actor, ok = AuthenticatedActor(principalBacked) require.True(t, ok) require.Equal(t, agent, actor) mode, ok = APIKeyAuthorization(principalBacked) require.True(t, ok) require.Equal(t, APIKeyAuthorizationModePrincipal, mode) + credential, ok := PrincipalCredentialAuthorization(principalBacked) + require.True(t, ok) + require.Equal(t, "user_authorizer", credential.AuthorizerUserID) + require.JSONEq(t, `{"requested":[],"effective":[]}`, string(credential.DelegatedGrants)) + require.False(t, func() bool { + _, _, ok := PrincipalCredentialProvenance(principalBacked) + return ok + }()) + + admitted := WithPrincipalCredentialOwner(principalBacked, "user_owner") + authorizer, owner, ok := PrincipalCredentialProvenance(admitted) + require.True(t, ok) + require.Equal(t, "user_authorizer", authorizer) + require.Equal(t, "user_owner", owner) + admittedActor, ok := AuthenticatedActor(admitted) + require.True(t, ok) + require.Equal(t, agent, admittedActor) } func TestValidatedGramSessionSetsCanonicalUserActor(t *testing.T) { From fd53a22d6800bdb1e8d8b657c10eb4af836fde50 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 4 Sep 2026 10:24:17 +0100 Subject: [PATCH 02/12] test: use subtest credential policy resource --- server/internal/authz/credential_admission_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/internal/authz/credential_admission_test.go b/server/internal/authz/credential_admission_test.go index e535265a57a..99a92e63807 100644 --- a/server/internal/authz/credential_admission_test.go +++ b/server/internal/authz/credential_admission_test.go @@ -114,6 +114,7 @@ func TestPrincipalCredentialAdmissionReloadsLivePolicies(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() fixture := newCredentialAdmissionFixture(t) + check := Check{Scope: ScopeProjectRead, ResourceID: fixture.projectID} principal := principalURN(fixture) //nolint:glint // notestingrawsql: simulate an authoritative live-policy removal and restoration _, err := fixture.db.Exec(t.Context(), `DELETE FROM principal_grants WHERE organization_id = $1 AND principal_urn = $2 AND scope = $3`, fixture.organizationID, principal, string(ScopeProjectRead)) From 26acc7783be66d1bbcf10b3570fd6131152ff2f8 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 4 Sep 2026 10:43:51 +0100 Subject: [PATCH 03/12] fix: harden principal credential admission --- server/design/access/design.go | 4 +- server/gen/http/access/client/types.go | 8 +-- server/gen/http/openapi3.yaml | 2 + server/internal/auth/key_test.go | 2 + server/internal/authz/credential_admission.go | 24 +++++++++ .../authz/credential_admission_test.go | 52 +++++++++++++++++++ server/internal/authz/delegated_policy.go | 29 +++++++++++ .../internal/authz/delegated_policy_test.go | 34 ++++++------ server/internal/chat/impl.go | 9 ++-- server/internal/contextvalues/context.go | 1 + server/internal/contextvalues/context_test.go | 10 ++++ server/internal/keys/queries.sql | 15 ++++++ server/internal/keys/repo/queries.sql.go | 39 ++++++++++++++ server/internal/projects/impl.go | 4 ++ 14 files changed, 207 insertions(+), 26 deletions(-) diff --git a/server/design/access/design.go b/server/design/access/design.go index 887849d4963..0e507ee5830 100644 --- a/server/design/access/design.go +++ b/server/design/access/design.go @@ -1081,7 +1081,7 @@ var AuthzChallengeModel = Type("AuthzChallenge", func() { Attribute("principal_urn", String, "Principal URN e.g. user: or api_key:.") Attribute("principal_type", String, func() { Description("Kind of principal.") - Enum("user", "api_key", "assistant") + Enum("user", "api_key", "assistant", "agent") }) Attribute("user_email", String, "Email when available.") Attribute("photo_url", String, "User avatar URL when available.") @@ -1141,7 +1141,7 @@ var ChallengeBucketModel = Type("ChallengeBucket", func() { Attribute("principal_urn", String, "Principal URN e.g. user: or api_key:.") Attribute("principal_type", String, func() { Description("Kind of principal.") - Enum("user", "api_key", "assistant") + Enum("user", "api_key", "assistant", "agent") }) Attribute("user_email", String, "Email when available.") Attribute("photo_url", String, "User avatar URL when available.") diff --git a/server/gen/http/access/client/types.go b/server/gen/http/access/client/types.go index aa961000283..90a76baf9ee 100644 --- a/server/gen/http/access/client/types.go +++ b/server/gen/http/access/client/types.go @@ -14397,8 +14397,8 @@ func ValidateAuthzChallengeResponseBody(body *AuthzChallengeResponseBody) (err e err = goa.MergeErrors(err, goa.ValidateFormat("body.timestamp", *body.Timestamp, goa.FormatDateTime)) } if body.PrincipalType != nil { - if !(*body.PrincipalType == "user" || *body.PrincipalType == "api_key" || *body.PrincipalType == "assistant") { - err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.principal_type", *body.PrincipalType, []any{"user", "api_key", "assistant"})) + if !(*body.PrincipalType == "user" || *body.PrincipalType == "api_key" || *body.PrincipalType == "assistant" || *body.PrincipalType == "agent") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.principal_type", *body.PrincipalType, []any{"user", "api_key", "assistant", "agent"})) } } if body.Operation != nil { @@ -14482,8 +14482,8 @@ func ValidateChallengeBucketResponseBody(body *ChallengeBucketResponseBody) (err err = goa.MergeErrors(err, goa.ValidateFormat("body.first_seen", *body.FirstSeen, goa.FormatDateTime)) } if body.PrincipalType != nil { - if !(*body.PrincipalType == "user" || *body.PrincipalType == "api_key" || *body.PrincipalType == "assistant") { - err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.principal_type", *body.PrincipalType, []any{"user", "api_key", "assistant"})) + if !(*body.PrincipalType == "user" || *body.PrincipalType == "api_key" || *body.PrincipalType == "assistant" || *body.PrincipalType == "agent") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("body.principal_type", *body.PrincipalType, []any{"user", "api_key", "assistant", "agent"})) } } if body.Operation != nil { diff --git a/server/gen/http/openapi3.yaml b/server/gen/http/openapi3.yaml index 733f08700fd..22730d995f0 100644 --- a/server/gen/http/openapi3.yaml +++ b/server/gen/http/openapi3.yaml @@ -68186,6 +68186,7 @@ components: - user - api_key - assistant + - agent principal_urn: type: string description: Principal URN e.g. user: or api_key:. @@ -68730,6 +68731,7 @@ components: - user - api_key - assistant + - agent principal_urn: type: string description: Principal URN e.g. user: or api_key:. diff --git a/server/internal/auth/key_test.go b/server/internal/auth/key_test.go index cc3d2ff25a3..384a8c871ac 100644 --- a/server/internal/auth/key_test.go +++ b/server/internal/auth/key_test.go @@ -84,6 +84,8 @@ func TestPrincipalAPIKeySupportsOnlyPrincipalSafeTransportRoutes(t *testing.T) { require.False(t, principalAPIKeySupportsTransportScopes(nil)) require.True(t, principalAPIKeySupportsTransportScopes([]string{"consumer"})) require.True(t, principalAPIKeySupportsTransportScopes([]string{"producer"})) + require.True(t, principalAPIKeySupportsTransportScopes([]string{"producer", "consumer"})) + require.False(t, principalAPIKeySupportsTransportScopes([]string{"producer", "agent"})) for _, scope := range []string{"agent", "agent_user", "chat", "hooks", "unknown"} { require.False(t, principalAPIKeySupportsTransportScopes([]string{scope}), scope) } diff --git a/server/internal/authz/credential_admission.go b/server/internal/authz/credential_admission.go index ac45d841d47..311a03c8b80 100644 --- a/server/internal/authz/credential_admission.go +++ b/server/internal/authz/credential_admission.go @@ -5,9 +5,12 @@ import ( "errors" "fmt" + "github.com/google/uuid" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "github.com/speakeasy-api/gram/server/internal/agents" "github.com/speakeasy-api/gram/server/internal/contextvalues" + keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" "github.com/speakeasy-api/gram/server/internal/o11y" "github.com/speakeasy-api/gram/server/internal/oops" "github.com/speakeasy-api/gram/server/internal/urn" @@ -45,6 +48,27 @@ func (e *Engine) AdmitPrincipalCredential(ctx context.Context) (context.Context, } defer o11y.NoLogDefer(func() error { return tx.Rollback(ctx) }) + if mode, hasMode := contextvalues.APIKeyAuthorization(ctx); hasMode && mode == contextvalues.APIKeyAuthorizationModePrincipal { + apiKeyID, parseErr := uuid.Parse(authCtx.APIKeyID) + if parseErr != nil { + return ctx, oops.C(oops.CodeUnauthorized) + } + _, err = keysrepo.New(tx).GetActivePrincipalAPIKeyForAdmission(ctx, keysrepo.GetActivePrincipalAPIKeyForAdmissionParams{ + ID: apiKeyID, + OrganizationID: authCtx.ActiveOrganizationID, + SubjectUrn: pgtype.Text{String: actor.String(), Valid: true}, + AuthorizerUserID: credential.AuthorizerUserID, + DelegatedGrants: credential.DelegatedGrants, + DelegatedGrantsVersion: pgtype.Int4{Int32: credential.DelegatedGrantsVersion, Valid: true}, + }) + if errors.Is(err, pgx.ErrNoRows) { + return ctx, oops.C(oops.CodeUnauthorized) + } + if err != nil { + return ctx, fmt.Errorf("revalidate principal API key: %w", err) + } + } + agent, err := agents.ResolvePrincipal(ctx, tx, authCtx.ActiveOrganizationID, actor) if err != nil { if errors.Is(err, agents.ErrPrincipalInvalid) || errors.Is(err, agents.ErrPrincipalNotFound) { diff --git a/server/internal/authz/credential_admission_test.go b/server/internal/authz/credential_admission_test.go index 99a92e63807..f150610be4a 100644 --- a/server/internal/authz/credential_admission_test.go +++ b/server/internal/authz/credential_admission_test.go @@ -13,6 +13,7 @@ import ( agentsrepo "github.com/speakeasy-api/gram/server/internal/agents/repo" "github.com/speakeasy-api/gram/server/internal/contextvalues" "github.com/speakeasy-api/gram/server/internal/conv" + keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" "github.com/speakeasy-api/gram/server/internal/oops" orgrepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" "github.com/speakeasy-api/gram/server/internal/testenv" @@ -70,6 +71,57 @@ func TestPrincipalCredentialAdmissionRejectsEachParentGate(t *testing.T) { } } +func TestPrincipalAPIKeyAdmissionRevalidatesCredentialActivity(t *testing.T) { + t.Parallel() + + for name, mutate := range map[string]func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID){ + "deleted": func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID) { + t.Helper() + _, err := keysrepo.New(fixture.db).DeleteAPIKey(t.Context(), keysrepo.DeleteAPIKeyParams{ID: keyID, OrganizationID: fixture.organizationID}) + require.NoError(t, err) + }, + "expired": func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID) { + t.Helper() + //nolint:glint // notestingrawsql: simulate expiry after the credential profile was loaded + _, err := fixture.db.Exec(t.Context(), `UPDATE api_keys SET expires_at = statement_timestamp() - INTERVAL '1 second' WHERE id = $1 AND organization_id = $2`, keyID, fixture.organizationID) + require.NoError(t, err) + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newCredentialAdmissionFixture(t) + authCtx, ok := contextvalues.GetAuthContext(fixture.requestContext) + require.True(t, ok) + actor, ok := contextvalues.AuthenticatedActor(fixture.requestContext) + require.True(t, ok) + credential, ok := contextvalues.PrincipalCredentialAuthorization(fixture.requestContext) + require.True(t, ok) + + keyID := uuid.New() + keyHash := uuid.NewString() + created, err := keysrepo.New(fixture.db).CreateAPIKey(t.Context(), keysrepo.CreateAPIKeyParams{ + OrganizationID: fixture.organizationID, CreatedByUserID: fixture.authorizerUserID, + Name: "admission-" + keyID.String(), KeyPrefix: "gram_test", KeyHash: keyHash, Scopes: []string{"producer"}, + }) + require.NoError(t, err) + //nolint:glint // notestingrawsql: AIM-194 owns the principal-key writer; this seeds its immutable profile + _, err = fixture.db.Exec(t.Context(), `UPDATE api_keys SET scopes = '{}', subject_urn = $1, delegated_grants = $2, delegated_grants_version = $3, expires_at = statement_timestamp() + INTERVAL '1 day' WHERE id = $4`, + actor.String(), credential.DelegatedGrants, credential.DelegatedGrantsVersion, created.ID) + require.NoError(t, err) + + keyAuth := *authCtx + keyAuth.APIKeyID = created.ID.String() + requestContext := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), &keyAuth, actor, credential) + _, err = fixture.engine.PrepareContext(requestContext) + require.NoError(t, err) + + mutate(t, fixture, created.ID) + _, err = fixture.engine.PrepareContext(requestContext) + requireUnauthorized(t, err) + }) + } +} + func TestPrincipalCredentialAdmissionIsTenantBound(t *testing.T) { t.Parallel() diff --git a/server/internal/authz/delegated_policy.go b/server/internal/authz/delegated_policy.go index 8967b8925e1..82d158a41e2 100644 --- a/server/internal/authz/delegated_policy.go +++ b/server/internal/authz/delegated_policy.go @@ -115,6 +115,9 @@ func DecodeDelegatedPolicy(version DelegatedPolicyVersion, raw []byte) (Delegate if err := rejectDuplicateJSONKeys(raw); err != nil { return DelegatedPolicy{}, invalidDelegatedPolicy("decode: %v", err) } + if err := rejectNonCanonicalDelegatedPolicyFields(raw); err != nil { + return DelegatedPolicy{}, invalidDelegatedPolicy("decode: %v", err) + } var wire struct { Requested []DelegatedPolicyGrant `json:"requested"` @@ -310,6 +313,32 @@ func cloneSelector(selector Selector) Selector { return cloned } +func rejectNonCanonicalDelegatedPolicyFields(raw []byte) error { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(raw, &envelope); err != nil { + return fmt.Errorf("decode policy envelope: %w", err) + } + if len(envelope) != 2 || envelope["requested"] == nil || envelope["effective"] == nil { + return errors.New("policy envelope fields must be exactly requested and effective") + } + for _, field := range []string{"requested", "effective"} { + var grants []json.RawMessage + if err := json.Unmarshal(envelope[field], &grants); err != nil { + return fmt.Errorf("decode %s grants: %w", field, err) + } + for i, rawGrant := range grants { + var grant map[string]json.RawMessage + if err := json.Unmarshal(rawGrant, &grant); err != nil { + return fmt.Errorf("decode %s grant %d: %w", field, i, err) + } + if len(grant) != 2 || grant["scope"] == nil || grant["selector"] == nil { + return fmt.Errorf("%s grant %d fields must be exactly scope and selector", field, i) + } + } + } + return nil +} + func rejectDuplicateJSONKeys(raw []byte) error { decoder := json.NewDecoder(bytes.NewReader(raw)) var visit func() error diff --git a/server/internal/authz/delegated_policy_test.go b/server/internal/authz/delegated_policy_test.go index 89754dbb362..86815d9c183 100644 --- a/server/internal/authz/delegated_policy_test.go +++ b/server/internal/authz/delegated_policy_test.go @@ -83,22 +83,24 @@ func TestDecodeDelegatedPolicyRejectsInvalidProfiles(t *testing.T) { version DelegatedPolicyVersion raw []byte }{ - "unsupported version": {version: 2, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{}, Effective: []DelegatedPolicyGrant{}})}, - "unknown envelope field": {version: 1, raw: []byte(`{"requested":[],"effective":[],"effect":"allow"}`)}, - "trailing value": {version: 1, raw: []byte(`{"requested":[],"effective":[]} {}`)}, - "missing requested": {version: 1, raw: []byte(`{"effective":[]}`)}, - "null effective": {version: 1, raw: []byte(`{"requested":[],"effective":null}`)}, - "unknown grant field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one"},"effect":"allow"}],"effective":[]}`)}, - "duplicate envelope field": {version: 1, raw: []byte(`{"requested":[],"requested":[],"effective":[]}`)}, - "duplicate grant field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","scope":"mcp:read","selector":{"resource_kind":"project","resource_id":"project-one"}}],"effective":[]}`)}, - "duplicate selector field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one","resource_id":"project-two"}}],"effective":[]}`)}, - "unordered requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, mcp}, Effective: []DelegatedPolicyGrant{mcp, project}})}, - "duplicate requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, project}, Effective: []DelegatedPolicyGrant{project}})}, - "duplicate effective": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project}, Effective: []DelegatedPolicyGrant{project, project}})}, - "noncanonical closure": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}})}, - "unsafe active scope": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}})}, - "malformed known selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}})}, - "malformed unknown selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}, Effective: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}})}, + "unsupported version": {version: 2, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{}, Effective: []DelegatedPolicyGrant{}})}, + "unknown envelope field": {version: 1, raw: []byte(`{"requested":[],"effective":[],"effect":"allow"}`)}, + "trailing value": {version: 1, raw: []byte(`{"requested":[],"effective":[]} {}`)}, + "missing requested": {version: 1, raw: []byte(`{"effective":[]}`)}, + "null effective": {version: 1, raw: []byte(`{"requested":[],"effective":null}`)}, + "unknown grant field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one"},"effect":"allow"}],"effective":[]}`)}, + "case variant envelope field": {version: 1, raw: []byte(`{"Requested":[],"effective":[]}`)}, + "case variant grant field": {version: 1, raw: []byte(`{"requested":[{"Scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one"}}],"effective":[]}`)}, + "duplicate envelope field": {version: 1, raw: []byte(`{"requested":[],"requested":[],"effective":[]}`)}, + "duplicate grant field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","scope":"mcp:read","selector":{"resource_kind":"project","resource_id":"project-one"}}],"effective":[]}`)}, + "duplicate selector field": {version: 1, raw: []byte(`{"requested":[{"scope":"project:read","selector":{"resource_kind":"project","resource_id":"project-one","resource_id":"project-two"}}],"effective":[]}`)}, + "unordered requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, mcp}, Effective: []DelegatedPolicyGrant{mcp, project}})}, + "duplicate requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, project}, Effective: []DelegatedPolicyGrant{project}})}, + "duplicate effective": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project}, Effective: []DelegatedPolicyGrant{project, project}})}, + "noncanonical closure": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}})}, + "unsafe active scope": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}})}, + "malformed known selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}})}, + "malformed unknown selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}, Effective: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}})}, } for name, test := range tests { diff --git a/server/internal/chat/impl.go b/server/internal/chat/impl.go index d56998967e8..8e9385b3eff 100644 --- a/server/internal/chat/impl.go +++ b/server/internal/chat/impl.go @@ -911,11 +911,12 @@ func (s *Service) authorizeChatAccess(ctx context.Context, authCtx *contextvalue // (chatsessions.Manager.Authorize), so APIKeyID alone does not prove the // caller authenticated *as* the key — treating it as first-party would let // an end user's chat-session token read every project chat. Only direct - // Gram-Key auth carries the key's scopes (auth.KeyBasedAuth); a chat-session - // token has none, so gate the exemption on the scopes being present. + // Gram-Key auth carries a trusted authorization mode (auth.KeyBasedAuth); a + // chat-session token has none, so gate the exemption on that private marker. _, isAssistantCall := contextvalues.GetAssistantPrincipal(ctx) - isDirectAPIKeyCall := authCtx.APIKeyID != "" && len(authCtx.APIKeyScopes) > 0 - isAPIKeyChatSession := authCtx.APIKeyID != "" && len(authCtx.APIKeyScopes) == 0 + _, hasDirectAPIKeyAuthorization := contextvalues.APIKeyAuthorization(ctx) + isDirectAPIKeyCall := authCtx.APIKeyID != "" && hasDirectAPIKeyAuthorization + isAPIKeyChatSession := authCtx.APIKeyID != "" && !hasDirectAPIKeyAuthorization if authCtx.SessionID == nil && !isDirectAPIKeyCall { if !isAssistantCall { if chat.ExternalUserID.String != "" && chat.ExternalUserID.String != authCtx.ExternalUserID { diff --git a/server/internal/contextvalues/context.go b/server/internal/contextvalues/context.go index ffe08803b91..4c7558da112 100644 --- a/server/internal/contextvalues/context.go +++ b/server/internal/contextvalues/context.go @@ -112,6 +112,7 @@ func WithPrincipalCredentialAuthorization(ctx context.Context, authCtx *AuthCont validated.actor = actor credential.DelegatedGrants = append([]byte(nil), credential.DelegatedGrants...) validated.principalCredential = &credential + validated.principalCredentialOwner = "" return SetAuthContext(ctx, &validated) } diff --git a/server/internal/contextvalues/context_test.go b/server/internal/contextvalues/context_test.go index a5709d6d470..2e08919191d 100644 --- a/server/internal/contextvalues/context_test.go +++ b/server/internal/contextvalues/context_test.go @@ -111,6 +111,16 @@ func TestAuthenticatedActorAndCredentialProvenanceAreIndependent(t *testing.T) { admittedActor, ok := AuthenticatedActor(admitted) require.True(t, ok) require.Equal(t, agent, admittedActor) + + admittedAuth, ok := GetAuthContext(admitted) + require.True(t, ok) + replaced := WithPrincipalCredentialAuthorization(admitted, admittedAuth, agent, PrincipalCredential{ + AuthorizerUserID: "user_new_authorizer", + DelegatedGrants: []byte(`{"requested":[],"effective":[]}`), + DelegatedGrantsVersion: 1, + }) + _, _, ok = PrincipalCredentialProvenance(replaced) + require.False(t, ok, "a new credential must not retain the previously admitted owner") } func TestValidatedGramSessionSetsCanonicalUserActor(t *testing.T) { diff --git a/server/internal/keys/queries.sql b/server/internal/keys/queries.sql index ece16543855..b8da34d9437 100644 --- a/server/internal/keys/queries.sql +++ b/server/internal/keys/queries.sql @@ -25,6 +25,21 @@ JOIN users ON users.id = api_keys.created_by_user_id WHERE key_hash = @key_hash AND deleted IS FALSE; +-- name: GetActivePrincipalAPIKeyForAdmission :one +SELECT id +FROM api_keys +WHERE id = @id + AND organization_id = @organization_id + AND deleted IS FALSE + AND cardinality(scopes) = 0 + AND subject_urn = @subject_urn + AND created_by_user_id = @authorizer_user_id + AND delegated_grants = @delegated_grants::jsonb + AND delegated_grants_version = @delegated_grants_version + AND expires_at > statement_timestamp() + AND expires_at > created_at + AND expires_at <= created_at + INTERVAL '365 days'; + -- name: ListAPIKeysByOrganization :many -- Deliberately does NOT join users the way GetAPIKeyByKeyHash does. A key -- whose created_by_user_id is not a users.id is unusable (auth's join drops diff --git a/server/internal/keys/repo/queries.sql.go b/server/internal/keys/repo/queries.sql.go index 51fdbd9acd6..926a7ccfc34 100644 --- a/server/internal/keys/repo/queries.sql.go +++ b/server/internal/keys/repo/queries.sql.go @@ -205,6 +205,45 @@ func (q *Queries) GetAPIKeyByKeyHash(ctx context.Context, keyHash string) (GetAP return i, err } +const getActivePrincipalAPIKeyForAdmission = `-- name: GetActivePrincipalAPIKeyForAdmission :one +SELECT id +FROM api_keys +WHERE id = $1 + AND organization_id = $2 + AND deleted IS FALSE + AND cardinality(scopes) = 0 + AND subject_urn = $3 + AND created_by_user_id = $4 + AND delegated_grants = $5::jsonb + AND delegated_grants_version = $6 + AND expires_at > statement_timestamp() + AND expires_at > created_at + AND expires_at <= created_at + INTERVAL '365 days' +` + +type GetActivePrincipalAPIKeyForAdmissionParams struct { + ID uuid.UUID + OrganizationID string + SubjectUrn pgtype.Text + AuthorizerUserID string + DelegatedGrants []byte + DelegatedGrantsVersion pgtype.Int4 +} + +func (q *Queries) GetActivePrincipalAPIKeyForAdmission(ctx context.Context, arg GetActivePrincipalAPIKeyForAdmissionParams) (uuid.UUID, error) { + row := q.db.QueryRow(ctx, getActivePrincipalAPIKeyForAdmission, + arg.ID, + arg.OrganizationID, + arg.SubjectUrn, + arg.AuthorizerUserID, + arg.DelegatedGrants, + arg.DelegatedGrantsVersion, + ) + var id uuid.UUID + err := row.Scan(&id) + return id, err +} + const isAPIKeyManagedByActiveLiteLLMInstance = `-- name: IsAPIKeyManagedByActiveLiteLLMInstance :one SELECT EXISTS ( SELECT 1 diff --git a/server/internal/projects/impl.go b/server/internal/projects/impl.go index 883cff1031d..6affcc1ec6a 100644 --- a/server/internal/projects/impl.go +++ b/server/internal/projects/impl.go @@ -546,6 +546,10 @@ func (s *Service) SetOrganizationWhitelist(ctx context.Context, payload *gen.Set return oops.E(oops.CodeUnauthorized, nil, "only speakeasy-team can set organization whitelist status").LogError(ctx, s.logger, attr.SlogOrganizationID(authCtx.ActiveOrganizationID)) } + if err := s.authz.Require(ctx, authz.Check{Scope: authz.ScopeOrgAdmin, ResourceKind: "", ResourceID: payload.OrganizationID, Dimensions: nil}); err != nil { + return err + } + err := s.repo.SetOrganizationWhitelist(ctx, repo.SetOrganizationWhitelistParams{ OrganizationID: payload.OrganizationID, Whitelisted: payload.Whitelisted, From 2b16cd63dbc47075e16fc64e29e7b1aa521f3dbe Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 4 Sep 2026 10:58:19 +0100 Subject: [PATCH 04/12] fix: preserve chat token ownership checks --- .speakeasy/out.openapi.yaml | 2 ++ .../src/sdk/src/models/components/authzchallenge.ts | 1 + .../sdk/src/models/components/challengebucket.ts | 1 + server/internal/chat/impl.go | 13 ++++++++----- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index 1c217632f41..b9c7a04f3c7 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -65660,6 +65660,7 @@ components: - user - api_key - assistant + - agent principal_urn: type: string description: Principal URN e.g. user: or api_key:. @@ -66070,6 +66071,7 @@ components: - user - api_key - assistant + - agent principal_urn: type: string description: Principal URN e.g. user: or api_key:. diff --git a/client/dashboard/src/sdk/src/models/components/authzchallenge.ts b/client/dashboard/src/sdk/src/models/components/authzchallenge.ts index 0042b8fc7e2..42e3eedc094 100644 --- a/client/dashboard/src/sdk/src/models/components/authzchallenge.ts +++ b/client/dashboard/src/sdk/src/models/components/authzchallenge.ts @@ -32,6 +32,7 @@ export const AuthzChallengePrincipalType = { User: "user", ApiKey: "api_key", Assistant: "assistant", + Agent: "agent", } as const; /** * Kind of principal. diff --git a/client/dashboard/src/sdk/src/models/components/challengebucket.ts b/client/dashboard/src/sdk/src/models/components/challengebucket.ts index 787ae0ca596..be18107619f 100644 --- a/client/dashboard/src/sdk/src/models/components/challengebucket.ts +++ b/client/dashboard/src/sdk/src/models/components/challengebucket.ts @@ -30,6 +30,7 @@ export const PrincipalType = { User: "user", ApiKey: "api_key", Assistant: "assistant", + Agent: "agent", } as const; /** * Kind of principal. diff --git a/server/internal/chat/impl.go b/server/internal/chat/impl.go index 8e9385b3eff..96e36b397ad 100644 --- a/server/internal/chat/impl.go +++ b/server/internal/chat/impl.go @@ -911,12 +911,15 @@ func (s *Service) authorizeChatAccess(ctx context.Context, authCtx *contextvalue // (chatsessions.Manager.Authorize), so APIKeyID alone does not prove the // caller authenticated *as* the key — treating it as first-party would let // an end user's chat-session token read every project chat. Only direct - // Gram-Key auth carries a trusted authorization mode (auth.KeyBasedAuth); a - // chat-session token has none, so gate the exemption on that private marker. + // Direct legacy Gram-Key auth carries scopes, while direct principal auth is + // identified by its private mode. API-key-minted chat tokens may retain legacy + // mode but have no scopes, so they still go through owner matching. _, isAssistantCall := contextvalues.GetAssistantPrincipal(ctx) - _, hasDirectAPIKeyAuthorization := contextvalues.APIKeyAuthorization(ctx) - isDirectAPIKeyCall := authCtx.APIKeyID != "" && hasDirectAPIKeyAuthorization - isAPIKeyChatSession := authCtx.APIKeyID != "" && !hasDirectAPIKeyAuthorization + apiKeyMode, hasAPIKeyAuthorization := contextvalues.APIKeyAuthorization(ctx) + isDirectLegacyAPIKey := hasAPIKeyAuthorization && apiKeyMode == contextvalues.APIKeyAuthorizationModeLegacy && len(authCtx.APIKeyScopes) > 0 + isDirectPrincipalAPIKey := hasAPIKeyAuthorization && apiKeyMode == contextvalues.APIKeyAuthorizationModePrincipal + isDirectAPIKeyCall := authCtx.APIKeyID != "" && (isDirectLegacyAPIKey || isDirectPrincipalAPIKey) + isAPIKeyChatSession := authCtx.APIKeyID != "" && !isDirectAPIKeyCall if authCtx.SessionID == nil && !isDirectAPIKeyCall { if !isAssistantCall { if chat.ExternalUserID.String != "" && chat.ExternalUserID.String != authCtx.ExternalUserID { From cf2704097c7ce0070ac8cad3535f0df021f1a481 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 4 Sep 2026 11:05:41 +0100 Subject: [PATCH 05/12] docs: clarify direct key chat handling --- server/internal/chat/impl.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/internal/chat/impl.go b/server/internal/chat/impl.go index 96e36b397ad..9711367f86f 100644 --- a/server/internal/chat/impl.go +++ b/server/internal/chat/impl.go @@ -910,8 +910,8 @@ func (s *Service) authorizeChatAccess(ctx context.Context, authCtx *contextvalue // A chat-session token minted via an API key restores that key's APIKeyID // (chatsessions.Manager.Authorize), so APIKeyID alone does not prove the // caller authenticated *as* the key — treating it as first-party would let - // an end user's chat-session token read every project chat. Only direct - // Direct legacy Gram-Key auth carries scopes, while direct principal auth is + // an end user's chat-session token read every project chat. Direct legacy + // Gram-Key auth carries scopes, while direct principal auth is // identified by its private mode. API-key-minted chat tokens may retain legacy // mode but have no scopes, so they still go through owner matching. _, isAssistantCall := contextvalues.GetAssistantPrincipal(ctx) From e4cc4a43a1ae8694793095a12b2bc111617ec43b Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 8 Sep 2026 21:47:27 +0100 Subject: [PATCH 06/12] fix: preserve runtime policy boundaries during credential admission restack --- server/cmd/gram/start.go | 4 +- server/cmd/gram/worker.go | 4 +- .../runtimepolicy/credential_admission.go | 118 +++++++ .../credential_admission_test.go | 317 +++++++++++++++++ .../runtimepolicy}/delegated_policy.go | 52 +-- .../runtimepolicy}/delegated_policy_test.go | 75 ++-- .../agents/runtimepolicy/setup_test.go | 68 ++++ server/internal/auth/authorize_test.go | 7 +- server/internal/auth/e2e_test.go | 3 +- server/internal/auth/setup_test.go | 5 +- server/internal/authz/credential_admission.go | 120 ++----- .../authz/credential_admission_test.go | 321 ++---------------- server/internal/authz/engine.go | 27 +- 13 files changed, 658 insertions(+), 463 deletions(-) create mode 100644 server/internal/agents/runtimepolicy/credential_admission.go create mode 100644 server/internal/agents/runtimepolicy/credential_admission_test.go rename server/internal/{authz => agents/runtimepolicy}/delegated_policy.go (87%) rename server/internal/{authz => agents/runtimepolicy}/delegated_policy_test.go (51%) create mode 100644 server/internal/agents/runtimepolicy/setup_test.go diff --git a/server/cmd/gram/start.go b/server/cmd/gram/start.go index a990269124f..424ea58ab3b 100644 --- a/server/cmd/gram/start.go +++ b/server/cmd/gram/start.go @@ -32,6 +32,7 @@ import ( "github.com/speakeasy-api/gram/server/internal/access" "github.com/speakeasy-api/gram/server/internal/agent" "github.com/speakeasy-api/gram/server/internal/agentmanagement" + "github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy" "github.com/speakeasy-api/gram/server/internal/aiintegrations" "github.com/speakeasy-api/gram/server/internal/assets" "github.com/speakeasy-api/gram/server/internal/assistant_platform_mcp_adapter" @@ -954,7 +955,8 @@ func newStartCommand() *cli.Command { challengeLoggingEnabled, roleClient, authz.EngineOpts{ - DevMode: c.String("environment") == "local", + AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential, + DevMode: c.String("environment") == "local", }) telemetryLogPublisher := tm.NewLogPublisher(logger, tracerProvider, meterProvider, publishers.TelemetryLogs) diff --git a/server/cmd/gram/worker.go b/server/cmd/gram/worker.go index c491a57c6b7..c985817f84a 100644 --- a/server/cmd/gram/worker.go +++ b/server/cmd/gram/worker.go @@ -17,6 +17,7 @@ import ( "go.temporal.io/sdk/client" "go.temporal.io/sdk/worker" + "github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy" "github.com/speakeasy-api/gram/server/internal/assistants" "github.com/speakeasy-api/gram/server/internal/attr" "github.com/speakeasy-api/gram/server/internal/auth/assistanttokens" @@ -562,7 +563,8 @@ func newWorkerCommand() *cli.Command { challengeLoggingEnabled, workos.NewStubClient(), authz.EngineOpts{ - DevMode: c.String("environment") == "local", + AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential, + DevMode: c.String("environment") == "local", }) workosClient, workosAvailable, err := newWorkOSClient(guardianPolicy, c) diff --git a/server/internal/agents/runtimepolicy/credential_admission.go b/server/internal/agents/runtimepolicy/credential_admission.go new file mode 100644 index 00000000000..4c61a792db0 --- /dev/null +++ b/server/internal/agents/runtimepolicy/credential_admission.go @@ -0,0 +1,118 @@ +package runtimepolicy + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/speakeasy-api/gram/server/internal/agents" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" + "github.com/speakeasy-api/gram/server/internal/o11y" + "github.com/speakeasy-api/gram/server/internal/oops" + "github.com/speakeasy-api/gram/server/internal/urn" +) + +// AdmitPrincipalCredential performs authoritative parent admission and loads +// immutable credential policy R, live direct agent policy A, and the current +// owner's live policy O as three independent policy sets. The caller must first +// load and validate the directly active credential row, stamp its immutable +// profile with contextvalues.WithPrincipalCredentialAuthorization, and call +// this function before minting credentials, resolving upstream authority, or +// executing an operation. Successful results must not be cached across requests. +func AdmitPrincipalCredential(ctx context.Context, db *pgxpool.Pool) (authz.PrincipalCredentialAdmission, error) { + authCtx, ok := contextvalues.GetAuthContext(ctx) + credential, hasCredential := contextvalues.PrincipalCredentialAuthorization(ctx) + actor, hasActor := contextvalues.AuthenticatedActor(ctx) + if !ok || authCtx == nil || !hasCredential || !hasActor || + authCtx.ActiveOrganizationID == "" || credential.AuthorizerUserID == "" || actor.Type != urn.PrincipalTypeAgent { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + + policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion(credential.DelegatedGrantsVersion), credential.DelegatedGrants) + if err != nil { + if errors.Is(err, ErrInvalidDelegatedPolicy) { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("decode delegated credential policy: %w", err) + } + + tx, err := db.BeginTx(ctx, pgx.TxOptions{ + IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly, DeferrableMode: pgx.NotDeferrable, BeginQuery: "", CommitQuery: "", + }) + if err != nil { + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("begin credential admission snapshot: %w", err) + } + defer o11y.NoLogDefer(func() error { return tx.Rollback(ctx) }) + + if mode, hasMode := contextvalues.APIKeyAuthorization(ctx); hasMode && mode == contextvalues.APIKeyAuthorizationModePrincipal { + apiKeyID, parseErr := uuid.Parse(authCtx.APIKeyID) + if parseErr != nil { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + _, err = keysrepo.New(tx).GetActivePrincipalAPIKeyForAdmission(ctx, keysrepo.GetActivePrincipalAPIKeyForAdmissionParams{ + ID: apiKeyID, + OrganizationID: authCtx.ActiveOrganizationID, + SubjectUrn: pgtype.Text{String: actor.String(), Valid: true}, + AuthorizerUserID: credential.AuthorizerUserID, + DelegatedGrants: credential.DelegatedGrants, + DelegatedGrantsVersion: pgtype.Int4{Int32: credential.DelegatedGrantsVersion, Valid: true}, + }) + if errors.Is(err, pgx.ErrNoRows) { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + if err != nil { + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("revalidate principal API key: %w", err) + } + } + + agent, err := agents.ResolvePrincipal(ctx, tx, authCtx.ActiveOrganizationID, actor) + if err != nil { + if errors.Is(err, agents.ErrPrincipalInvalid) || errors.Is(err, agents.ErrPrincipalNotFound) { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("resolve credential parent: %w", err) + } + if agents.DeriveLifecycle(agent) != agents.LifecycleActive || agent.OwnerReassignmentRequiredAt.Valid { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + + ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, agent.OwnerUserID) + ownerPrincipals, err := authz.ResolveUserPrincipals(ctx, tx, authCtx.ActiveOrganizationID, agent.OwnerUserID) + if err != nil { + if errors.Is(err, authz.ErrPrincipalInvalid) || errors.Is(err, authz.ErrPrincipalNotFound) { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("resolve credential owner: %w", err) + } + ownerEligible := false + for _, principal := range ownerPrincipals { + if principal.String() == ownerPrincipal.String() { + ownerEligible = true + break + } + } + if !ownerEligible { + return authz.PrincipalCredentialAdmission{}, oops.C(oops.CodeUnauthorized) + } + + agentPolicy, err := LoadAgentPolicy(ctx, tx, authCtx.ActiveOrganizationID, actor) + if err != nil { + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("load live agent policy: %w", err) + } + ownerPolicy, err := authz.LoadGrants(ctx, tx, authCtx.ActiveOrganizationID, ownerPrincipals) + if err != nil { + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("load live owner policy: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return authz.PrincipalCredentialAdmission{}, fmt.Errorf("commit credential admission snapshot: %w", err) + } + + return authz.PrincipalCredentialAdmission{OwnerUserID: agent.OwnerUserID, Credential: policy.RuntimeGrants(), Agent: agentPolicy, Owner: ownerPolicy}, nil +} diff --git a/server/internal/agents/runtimepolicy/credential_admission_test.go b/server/internal/agents/runtimepolicy/credential_admission_test.go new file mode 100644 index 00000000000..bbc5bbf7c28 --- /dev/null +++ b/server/internal/agents/runtimepolicy/credential_admission_test.go @@ -0,0 +1,317 @@ +package runtimepolicy + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + agentsrepo "github.com/speakeasy-api/gram/server/internal/agents/repo" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/conv" + keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" + "github.com/speakeasy-api/gram/server/internal/oops" + orgrepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" + "github.com/speakeasy-api/gram/server/internal/testenv" + "github.com/speakeasy-api/gram/server/internal/thirdparty/workos" + "github.com/speakeasy-api/gram/server/internal/urn" + usersrepo "github.com/speakeasy-api/gram/server/internal/users/repo" + "github.com/stretchr/testify/require" +) + +func TestPrincipalCredentialAdmissionRejectsEachParentGate(t *testing.T) { + t.Parallel() + + mutations := map[string]func(t *testing.T, fixture credentialAdmissionFixture){ + "suspended agent": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).SuspendAgent(t.Context(), agentsrepo.SuspendAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) + require.NoError(t, err) + }, + "revoked agent": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).RevokeAgent(t.Context(), agentsrepo.RevokeAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) + require.NoError(t, err) + }, + "deleted agent": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).DeleteAgent(t.Context(), agentsrepo.DeleteAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) + require.NoError(t, err) + }, + "owner reassignment required": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + _, err := agentsrepo.New(fixture.db).LatchAgentsForOwnerLossByMembership(t.Context(), agentsrepo.LatchAgentsForOwnerLossByMembershipParams{ + OwnerReassignmentReason: pgtype.Text{String: "membership_loss", Valid: true}, + OrganizationID: fixture.organizationID, + OwnerUserID: fixture.ownerUserID, + }) + require.NoError(t, err) + }, + "owner ineligible": func(t *testing.T, fixture credentialAdmissionFixture) { + t.Helper() + err := orgrepo.New(fixture.db).DeleteOrganizationUserRelationship(t.Context(), orgrepo.DeleteOrganizationUserRelationshipParams{ + OrganizationID: fixture.organizationID, + UserID: conv.ToPGText(fixture.ownerUserID), + }) + require.NoError(t, err) + }, + } + + for name, mutate := range mutations { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newCredentialAdmissionFixture(t) + mutate(t, fixture) + _, err := fixture.engine.PrepareContext(fixture.requestContext) + requireUnauthorized(t, err) + }) + } +} + +func TestPrincipalAPIKeyAdmissionRevalidatesCredentialActivity(t *testing.T) { + t.Parallel() + + for name, mutate := range map[string]func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID){ + "deleted": func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID) { + t.Helper() + _, err := keysrepo.New(fixture.db).DeleteAPIKey(t.Context(), keysrepo.DeleteAPIKeyParams{ID: keyID, OrganizationID: fixture.organizationID}) + require.NoError(t, err) + }, + "expired": func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID) { + t.Helper() + //nolint:glint // notestingrawsql: simulate expiry after the credential profile was loaded + _, err := fixture.db.Exec(t.Context(), `UPDATE api_keys SET expires_at = statement_timestamp() - INTERVAL '1 second' WHERE id = $1 AND organization_id = $2`, keyID, fixture.organizationID) + require.NoError(t, err) + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newCredentialAdmissionFixture(t) + authCtx, ok := contextvalues.GetAuthContext(fixture.requestContext) + require.True(t, ok) + actor, ok := contextvalues.AuthenticatedActor(fixture.requestContext) + require.True(t, ok) + credential, ok := contextvalues.PrincipalCredentialAuthorization(fixture.requestContext) + require.True(t, ok) + + keyID := uuid.New() + keyHash := uuid.NewString() + created, err := keysrepo.New(fixture.db).CreateAPIKey(t.Context(), keysrepo.CreateAPIKeyParams{ + OrganizationID: fixture.organizationID, CreatedByUserID: fixture.authorizerUserID, + Name: "admission-" + keyID.String(), KeyPrefix: "gram_test", KeyHash: keyHash, Scopes: []string{"producer"}, + }) + require.NoError(t, err) + //nolint:glint // notestingrawsql: AIM-194 owns the principal-key writer; this seeds its immutable profile + _, err = fixture.db.Exec(t.Context(), `UPDATE api_keys SET scopes = '{}', subject_urn = $1, delegated_grants = $2, delegated_grants_version = $3, expires_at = statement_timestamp() + INTERVAL '1 day' WHERE id = $4`, + actor.String(), credential.DelegatedGrants, credential.DelegatedGrantsVersion, created.ID) + require.NoError(t, err) + + keyAuth := *authCtx + keyAuth.APIKeyID = created.ID.String() + requestContext := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), &keyAuth, actor, credential) + _, err = fixture.engine.PrepareContext(requestContext) + require.NoError(t, err) + + mutate(t, fixture, created.ID) + _, err = fixture.engine.PrepareContext(requestContext) + requireUnauthorized(t, err) + }) + } +} + +func TestPrincipalCredentialAdmissionIsTenantBound(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + otherOrganizationID := "org-admission-other-" + uuid.NewString() + seedOrganization(t, t.Context(), fixture.db, otherOrganizationID) + authCtx, ok := contextvalues.GetAuthContext(fixture.requestContext) + require.True(t, ok) + crossTenant := *authCtx + crossTenant.ActiveOrganizationID = otherOrganizationID + credential, ok := contextvalues.PrincipalCredentialAuthorization(fixture.requestContext) + require.True(t, ok) + actor, ok := contextvalues.AuthenticatedActor(fixture.requestContext) + require.True(t, ok) + ctx := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &crossTenant, actor, credential) + + _, err := fixture.engine.PrepareContext(ctx) + requireUnauthorized(t, err) +} + +func TestPrincipalCredentialAdmissionReloadsLivePolicies(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + check := authz.Check{Scope: authz.ScopeProjectRead, ResourceID: fixture.projectID} + + prepared, err := fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + require.NoError(t, fixture.engine.Require(prepared, check)) + actor, ok := contextvalues.AuthenticatedActor(prepared) + require.True(t, ok) + require.Equal(t, "agent:"+fixture.agentID.String(), actor.String()) + authorizer, owner, ok := contextvalues.PrincipalCredentialProvenance(prepared) + require.True(t, ok) + require.Equal(t, fixture.authorizerUserID, authorizer) + require.Equal(t, fixture.ownerUserID, owner) + + for name, principalURN := range map[string]func(credentialAdmissionFixture) string{ + "agent A": func(f credentialAdmissionFixture) string { return "agent:" + f.agentID.String() }, + "owner O": func(f credentialAdmissionFixture) string { return "user:" + f.ownerUserID }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + fixture := newCredentialAdmissionFixture(t) + check := authz.Check{Scope: authz.ScopeProjectRead, ResourceID: fixture.projectID} + principal := principalURN(fixture) + //nolint:glint // notestingrawsql: simulate an authoritative live-policy removal and restoration + _, err := fixture.db.Exec(t.Context(), `DELETE FROM principal_grants WHERE organization_id = $1 AND principal_urn = $2 AND scope = $3`, fixture.organizationID, principal, string(authz.ScopeProjectRead)) + require.NoError(t, err) + + prepared, err := fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + var denied *oops.ShareableError + require.ErrorAs(t, fixture.engine.Require(prepared, check), &denied) + require.Equal(t, oops.CodeForbidden, denied.Code) + + parsed, err := urn.ParsePrincipal(principal) + require.NoError(t, err) + seedGrant(t, t.Context(), fixture.db, fixture.organizationID, parsed, authz.ScopeProjectRead, fixture.projectID) + prepared, err = fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + require.NoError(t, fixture.engine.Require(prepared, check), "restoration may reactivate authority still present in immutable R") + }) + } +} + +func TestPrincipalCredentialAdmissionUsesOnlyCurrentOwnerAfterTransfer(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + newOwnerUserID := "new-owner-" + uuid.NewString() + _, err := usersrepo.New(fixture.db).UpsertUser(t.Context(), usersrepo.UpsertUserParams{ + ID: newOwnerUserID, Email: newOwnerUserID + "@example.com", DisplayName: newOwnerUserID, PhotoUrl: conv.PtrToPGText(nil), Admin: false, + }) + require.NoError(t, err) + _, err = orgrepo.New(fixture.db).UpsertOrganizationUserRelationship(t.Context(), orgrepo.UpsertOrganizationUserRelationshipParams{ + OrganizationID: fixture.organizationID, UserID: conv.ToPGText(newOwnerUserID), + }) + require.NoError(t, err) + _, err = agentsrepo.New(fixture.db).TransferAgent(t.Context(), agentsrepo.TransferAgentParams{ + OwnerUserID: newOwnerUserID, OrganizationID: fixture.organizationID, ID: fixture.agentID, + }) + require.NoError(t, err) + + check := authz.Check{Scope: authz.ScopeProjectRead, ResourceID: fixture.projectID} + prepared, err := fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + var denied *oops.ShareableError + require.ErrorAs(t, fixture.engine.Require(prepared, check), &denied, "former owner's grants cannot authorize after transfer") + require.Equal(t, oops.CodeForbidden, denied.Code) + + seedGrant(t, t.Context(), fixture.db, fixture.organizationID, urn.NewPrincipal(urn.PrincipalTypeUser, newOwnerUserID), authz.ScopeProjectRead, fixture.projectID) + prepared, err = fixture.engine.PrepareContext(fixture.requestContext) + require.NoError(t, err) + require.NoError(t, fixture.engine.Require(prepared, check)) + _, owner, ok := contextvalues.PrincipalCredentialProvenance(prepared) + require.True(t, ok) + require.Equal(t, newOwnerUserID, owner) +} + +func TestPrincipalCredentialAdmissionDeniesEveryCheckAfterRevocationCommit(t *testing.T) { + t.Parallel() + + fixture := newCredentialAdmissionFixture(t) + start := make(chan struct{}) + results := make(chan error, 32) + var workers sync.WaitGroup + for range 32 { + workers.Go(func() { + <-start + _, err := fixture.engine.PrepareContext(fixture.requestContext) + results <- err + }) + } + + _, err := agentsrepo.New(fixture.db).RevokeAgent(t.Context(), agentsrepo.RevokeAgentParams{ + OrganizationID: fixture.organizationID, + ID: fixture.agentID, + }) + require.NoError(t, err) + close(start) + workers.Wait() + close(results) + for err := range results { + requireUnauthorized(t, err) + } +} + +type credentialAdmissionFixture struct { + db *pgxpool.Pool + engine *authz.Engine + requestContext context.Context //nolint:containedctx // immutable request authentication fixture + organizationID string + ownerUserID string + authorizerUserID string + agentID uuid.UUID + projectID string +} + +func newCredentialAdmissionFixture(t *testing.T) credentialAdmissionFixture { + t.Helper() + ctx := t.Context() + db := newTestDB(t) + organizationID := "org-admission-" + uuid.NewString() + ownerUserID := "owner-" + uuid.NewString() + authorizerUserID := "authorizer-" + uuid.NewString() + projectID := "project-" + uuid.NewString() + seedOrganization(t, ctx, db, organizationID) + + for _, userID := range []string{ownerUserID, authorizerUserID} { + _, err := usersrepo.New(db).UpsertUser(ctx, usersrepo.UpsertUserParams{ + ID: userID, Email: userID + "@example.com", DisplayName: userID, PhotoUrl: conv.PtrToPGText(nil), Admin: false, + }) + require.NoError(t, err) + _, err = orgrepo.New(db).UpsertOrganizationUserRelationship(ctx, orgrepo.UpsertOrganizationUserRelationshipParams{ + OrganizationID: organizationID, UserID: conv.ToPGText(userID), + }) + require.NoError(t, err) + } + + agent, err := agentsrepo.New(db).CreateAgent(ctx, agentsrepo.CreateAgentParams{ + OrganizationID: organizationID, OwnerUserID: ownerUserID, Name: "Credential admission agent", + }) + require.NoError(t, err) + agentPrincipal := urn.NewPrincipal(urn.PrincipalTypeAgent, agent.ID.String()) + ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, ownerUserID) + seedGrant(t, ctx, db, organizationID, agentPrincipal, authz.ScopeProjectRead, projectID) + seedGrant(t, ctx, db, organizationID, ownerPrincipal, authz.ScopeProjectRead, projectID) + + policy, err := NewDelegatedPolicyV1([]authz.Grant{authz.NewGrant(authz.ScopeProjectRead, projectID)}) + require.NoError(t, err) + rawPolicy, err := EncodeDelegatedPolicy(CurrentDelegatedPolicyVersion, policy) + require.NoError(t, err) + requestContext := contextvalues.WithPrincipalCredentialAuthorization(ctx, &contextvalues.AuthContext{ + ActiveOrganizationID: organizationID, + }, agentPrincipal, contextvalues.PrincipalCredential{ + AuthorizerUserID: authorizerUserID, + DelegatedGrants: rawPolicy, + DelegatedGrantsVersion: int32(CurrentDelegatedPolicyVersion), + }) + + return credentialAdmissionFixture{ + db: db, engine: authz.NewEngine(testenv.NewLogger(t), db, func(context.Context, string) (bool, error) { return false, nil }, workos.NewStubClient(), authz.EngineOpts{AdmitPrincipalCredential: AdmitPrincipalCredential}), requestContext: requestContext, + organizationID: organizationID, ownerUserID: ownerUserID, authorizerUserID: authorizerUserID, agentID: agent.ID, projectID: projectID, + } +} + +func requireUnauthorized(t *testing.T, err error) { + t.Helper() + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeUnauthorized, oopsErr.Code) +} diff --git a/server/internal/authz/delegated_policy.go b/server/internal/agents/runtimepolicy/delegated_policy.go similarity index 87% rename from server/internal/authz/delegated_policy.go rename to server/internal/agents/runtimepolicy/delegated_policy.go index 82d158a41e2..6c50f03e86a 100644 --- a/server/internal/authz/delegated_policy.go +++ b/server/internal/agents/runtimepolicy/delegated_policy.go @@ -1,4 +1,4 @@ -package authz +package runtimepolicy import ( "bytes" @@ -8,6 +8,8 @@ import ( "io" "maps" "slices" + + "github.com/speakeasy-api/gram/server/internal/authz" ) // DelegatedPolicyVersion identifies the persisted delegated-policy format and @@ -26,8 +28,8 @@ var ErrInvalidDelegatedPolicy = errors.New("invalid delegated policy") // DelegatedPolicyGrant is one canonical allow grant. The absence of an effect // field is intentional: delegated policies cannot contain deny-effect rows. type DelegatedPolicyGrant struct { - Scope Scope `json:"scope"` - Selector Selector `json:"selector"` + Scope authz.Scope `json:"scope"` + Selector authz.Selector `json:"selector"` } // DelegatedPolicy is the versioned credential-policy envelope. Requested is @@ -36,18 +38,18 @@ type DelegatedPolicy struct { Requested []DelegatedPolicyGrant `json:"requested"` Effective []DelegatedPolicyGrant `json:"effective"` - runtimeGrants []Grant + runtimeGrants []authz.Grant } // NewDelegatedPolicyV1 constructs the current canonical policy from requested // grants and records their explicit implication closure in Effective. -func NewDelegatedPolicyV1(requested []Grant) (DelegatedPolicy, error) { +func NewDelegatedPolicyV1(requested []authz.Grant) (DelegatedPolicy, error) { return NewDelegatedPolicy(DelegatedPolicyVersion1, requested) } // NewDelegatedPolicy constructs a canonical policy suitable for persistence by // a future credential issuer. Issuers may only use active agent-runtime scopes. -func NewDelegatedPolicy(version DelegatedPolicyVersion, requested []Grant) (DelegatedPolicy, error) { +func NewDelegatedPolicy(version DelegatedPolicyVersion, requested []authz.Grant) (DelegatedPolicy, error) { if err := validateDelegatedPolicyVersion(version); err != nil { return DelegatedPolicy{}, err } @@ -55,10 +57,10 @@ func NewDelegatedPolicy(version DelegatedPolicyVersion, requested []Grant) (Dele wireRequested := make([]DelegatedPolicyGrant, 0, len(requested)) seen := make(map[string]struct{}, len(requested)) for _, grant := range requested { - if err := ValidateAgentRuntimeScope(AgentRuntimeScopeRegistryVersion(version), grant.Scope); err != nil { + if err := ValidateRuntimeScope(RuntimeScopeRegistryVersion(version), grant.Scope); err != nil { return DelegatedPolicy{}, invalidDelegatedPolicy("validate requested scope %q: %v", grant.Scope, err) } - if err := ValidateSelector(grant.Scope, grant.Selector); err != nil { + if err := authz.ValidateSelector(grant.Scope, grant.Selector); err != nil { return DelegatedPolicy{}, invalidDelegatedPolicy("validate requested selector for %q: %v", grant.Scope, err) } @@ -144,10 +146,10 @@ func DecodeDelegatedPolicy(version DelegatedPolicyVersion, raw []byte) (Delegate // RuntimeGrants returns a defensive copy of the stored effective policy entries // that are active and agent-runtime-safe for the policy version. -func (p DelegatedPolicy) RuntimeGrants() []Grant { - grants := make([]Grant, len(p.runtimeGrants)) +func (p DelegatedPolicy) RuntimeGrants() []authz.Grant { + grants := make([]authz.Grant, len(p.runtimeGrants)) for i, grant := range p.runtimeGrants { - grants[i] = Grant{PrincipalUrn: "", Scope: grant.Scope, Selector: cloneSelector(grant.Selector)} + grants[i] = authz.Grant{PrincipalUrn: "", Scope: grant.Scope, Selector: cloneSelector(grant.Selector)} } return grants } @@ -207,28 +209,28 @@ func validateStoredDelegatedPolicyGrant(version DelegatedPolicyVersion, grant De if grant.Selector == nil { return errors.New("selector must be an object") } - if _, ok := grant.Selector[SelectorKeyResourceKind]; !ok { + if _, ok := grant.Selector[authz.SelectorKeyResourceKind]; !ok { return errors.New("selector must include resource_kind") } - if _, ok := grant.Selector[SelectorKeyResourceID]; !ok { + if _, ok := grant.Selector[authz.SelectorKeyResourceID]; !ok { return errors.New("selector must include resource_id") } - definition, known := scopeDefinitions[grant.Scope] - if !known || definition.lifecycle == ScopeLifecycleRetired { + definition, known := runtimeScopeDefinitions[grant.Scope] + if !known || definition.lifecycle == RuntimeScopeLifecycleRetired { return nil } - if definition.agentRuntimeSafeSince == 0 || definition.agentRuntimeSafeSince > AgentRuntimeScopeRegistryVersion(version) { + if definition.safeSince == 0 || definition.safeSince > RuntimeScopeRegistryVersion(version) { return fmt.Errorf("scope %q is not agent-runtime-safe", grant.Scope) } - return ValidateSelector(grant.Scope, grant.Selector) + return authz.ValidateSelector(grant.Scope, grant.Selector) } func delegatedPolicyClosure(requested []DelegatedPolicyGrant) ([]DelegatedPolicyGrant, error) { effective := make([]DelegatedPolicyGrant, 0, len(requested)) seen := make(map[string]struct{}, len(requested)) for _, grant := range requested { - for _, scope := range AgentRuntimeScopeImplicationClosure(grant.Scope) { + for _, scope := range authz.ScopeImplicationClosure(grant.Scope) { implied := DelegatedPolicyGrant{Scope: scope, Selector: cloneSelector(grant.Selector)} key, err := delegatedPolicyGrantKey(implied) if err != nil { @@ -245,14 +247,14 @@ func delegatedPolicyClosure(requested []DelegatedPolicyGrant) ([]DelegatedPolicy return effective, nil } -func runtimeGrants(version DelegatedPolicyVersion, effective []DelegatedPolicyGrant) []Grant { - grants := make([]Grant, 0, len(effective)) +func runtimeGrants(version DelegatedPolicyVersion, effective []DelegatedPolicyGrant) []authz.Grant { + grants := make([]authz.Grant, 0, len(effective)) for _, grant := range effective { - definition, known := scopeDefinitions[grant.Scope] - if !known || definition.lifecycle != ScopeLifecycleActive || definition.agentRuntimeSafeSince == 0 || definition.agentRuntimeSafeSince > AgentRuntimeScopeRegistryVersion(version) { + definition, known := runtimeScopeDefinitions[grant.Scope] + if !known || definition.lifecycle != RuntimeScopeLifecycleActive || definition.safeSince == 0 || definition.safeSince > RuntimeScopeRegistryVersion(version) { continue } - grants = append(grants, Grant{PrincipalUrn: "", Scope: grant.Scope, Selector: cloneSelector(grant.Selector)}) + grants = append(grants, authz.Grant{PrincipalUrn: "", Scope: grant.Scope, Selector: cloneSelector(grant.Selector)}) } return grants } @@ -304,11 +306,11 @@ func cloneDelegatedPolicyGrants(grants []DelegatedPolicyGrant) []DelegatedPolicy return cloned } -func cloneSelector(selector Selector) Selector { +func cloneSelector(selector authz.Selector) authz.Selector { if selector == nil { return nil } - cloned := make(Selector, len(selector)) + cloned := make(authz.Selector, len(selector)) maps.Copy(cloned, selector) return cloned } diff --git a/server/internal/authz/delegated_policy_test.go b/server/internal/agents/runtimepolicy/delegated_policy_test.go similarity index 51% rename from server/internal/authz/delegated_policy_test.go rename to server/internal/agents/runtimepolicy/delegated_policy_test.go index 86815d9c183..7a8c31f91a1 100644 --- a/server/internal/authz/delegated_policy_test.go +++ b/server/internal/agents/runtimepolicy/delegated_policy_test.go @@ -1,32 +1,33 @@ -package authz +package runtimepolicy import ( "encoding/json" "testing" + "github.com/speakeasy-api/gram/server/internal/authz" "github.com/stretchr/testify/require" ) func TestNewDelegatedPolicyV1CanonicalizesAndClosesImplications(t *testing.T) { t.Parallel() - policy, err := NewDelegatedPolicyV1([]Grant{ - NewGrant(ScopeProjectRead, "project-one"), - {Scope: ScopeMCPWrite, Selector: Selector{ - SelectorKeyResourceKind: ResourceKindMCP, - SelectorKeyResourceID: "server-one", - SelectorKeyTool: "tool-one", + policy, err := NewDelegatedPolicyV1([]authz.Grant{ + authz.NewGrant(authz.ScopeProjectRead, "project-one"), + {Scope: authz.ScopeMCPWrite, Selector: authz.Selector{ + authz.SelectorKeyResourceKind: authz.ResourceKindMCP, + authz.SelectorKeyResourceID: "server-one", + authz.SelectorKeyTool: "tool-one", }}, }) require.NoError(t, err) - require.Equal(t, []Scope{ScopeMCPWrite, ScopeProjectRead}, delegatedPolicyScopes(policy.Requested)) - require.Equal(t, []Scope{ScopeMCPConnect, ScopeMCPRead, ScopeMCPWrite, ScopeProjectRead}, delegatedPolicyScopes(policy.Effective)) + require.Equal(t, []authz.Scope{authz.ScopeMCPWrite, authz.ScopeProjectRead}, delegatedPolicyScopes(policy.Requested)) + require.Equal(t, []authz.Scope{authz.ScopeMCPConnect, authz.ScopeMCPRead, authz.ScopeMCPWrite, authz.ScopeProjectRead}, delegatedPolicyScopes(policy.Effective)) runtime := policy.RuntimeGrants() require.Len(t, runtime, 4) - require.True(t, GrantsSatisfy(runtime, MCPToolCallCheck("server-one", MCPToolCallDimensions{Tool: "tool-one"}))) - require.True(t, GrantsSatisfy(runtime, Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) + require.True(t, authz.GrantsSatisfy(runtime, authz.MCPToolCallCheck("server-one", authz.MCPToolCallDimensions{Tool: "tool-one"}))) + require.True(t, authz.GrantsSatisfy(runtime, authz.Check{Scope: authz.ScopeProjectRead, ResourceID: "project-one"})) encoded, err := EncodeDelegatedPolicy(DelegatedPolicyVersion1, policy) require.NoError(t, err) @@ -39,45 +40,45 @@ func TestNewDelegatedPolicyV1CanonicalizesAndClosesImplications(t *testing.T) { func TestDecodeDelegatedPolicyUsesEffectivePolicy(t *testing.T) { t.Parallel() - selector := NewSelector(ScopeProjectRead, "project-one") + selector := authz.NewSelector(authz.ScopeProjectRead, "project-one") raw := mustPolicyJSON(t, DelegatedPolicy{ - Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: selector}}, - Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: selector}}, + Requested: []DelegatedPolicyGrant{{Scope: authz.ScopeProjectRead, Selector: selector}}, + Effective: []DelegatedPolicyGrant{{Scope: authz.ScopeProjectRead, Selector: selector}}, }) policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion1, raw) require.NoError(t, err) - policy.Requested[0].Selector[SelectorKeyResourceID] = "mutated-request" + policy.Requested[0].Selector[authz.SelectorKeyResourceID] = "mutated-request" runtime := policy.RuntimeGrants() - require.True(t, GrantsSatisfy(runtime, Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) - require.False(t, GrantsSatisfy(runtime, Check{Scope: ScopeProjectRead, ResourceID: "mutated-request"})) + require.True(t, authz.GrantsSatisfy(runtime, authz.Check{Scope: authz.ScopeProjectRead, ResourceID: "project-one"})) + require.False(t, authz.GrantsSatisfy(runtime, authz.Check{Scope: authz.ScopeProjectRead, ResourceID: "mutated-request"})) - runtime[0].Selector[SelectorKeyResourceID] = "mutated-runtime" - require.True(t, GrantsSatisfy(policy.RuntimeGrants(), Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) + runtime[0].Selector[authz.SelectorKeyResourceID] = "mutated-runtime" + require.True(t, authz.GrantsSatisfy(policy.RuntimeGrants(), authz.Check{Scope: authz.ScopeProjectRead, ResourceID: "project-one"})) } func TestDecodeDelegatedPolicySkipsUnknownAndRetiredEntries(t *testing.T) { t.Parallel() entries := []DelegatedPolicyGrant{ - {Scope: scopeMCPApprovalReadTombstone, Selector: Selector{SelectorKeyResourceKind: "mcp_approval", SelectorKeyResourceID: "approval-one"}}, - {Scope: ScopeProjectRead, Selector: NewSelector(ScopeProjectRead, "project-one")}, - {Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown", SelectorKeyResourceID: "unknown-one"}}, + {Scope: scopeMCPApprovalReadTombstone, Selector: authz.Selector{authz.SelectorKeyResourceKind: "mcp_approval", authz.SelectorKeyResourceID: "approval-one"}}, + {Scope: authz.ScopeProjectRead, Selector: authz.NewSelector(authz.ScopeProjectRead, "project-one")}, + {Scope: authz.Scope("unknown:scope"), Selector: authz.Selector{authz.SelectorKeyResourceKind: "unknown", authz.SelectorKeyResourceID: "unknown-one"}}, } raw := mustPolicyJSON(t, DelegatedPolicy{Requested: entries, Effective: entries}) policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion1, raw) require.NoError(t, err) require.Len(t, policy.Requested, 3) - require.Equal(t, []Scope{ScopeProjectRead}, grantScopes(policy.RuntimeGrants())) - require.True(t, GrantsSatisfy(policy.RuntimeGrants(), Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) + require.Equal(t, []authz.Scope{authz.ScopeProjectRead}, grantScopes(policy.RuntimeGrants())) + require.True(t, authz.GrantsSatisfy(policy.RuntimeGrants(), authz.Check{Scope: authz.ScopeProjectRead, ResourceID: "project-one"})) } func TestDecodeDelegatedPolicyRejectsInvalidProfiles(t *testing.T) { t.Parallel() - project := DelegatedPolicyGrant{Scope: ScopeProjectRead, Selector: NewSelector(ScopeProjectRead, "project-one")} - mcp := DelegatedPolicyGrant{Scope: ScopeMCPRead, Selector: NewSelector(ScopeMCPRead, "server-one")} + project := DelegatedPolicyGrant{Scope: authz.ScopeProjectRead, Selector: authz.NewSelector(authz.ScopeProjectRead, "project-one")} + mcp := DelegatedPolicyGrant{Scope: authz.ScopeMCPRead, Selector: authz.NewSelector(authz.ScopeMCPRead, "server-one")} tests := map[string]struct { version DelegatedPolicyVersion @@ -97,10 +98,10 @@ func TestDecodeDelegatedPolicyRejectsInvalidProfiles(t *testing.T) { "unordered requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, mcp}, Effective: []DelegatedPolicyGrant{mcp, project}})}, "duplicate requested": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project, project}, Effective: []DelegatedPolicyGrant{project}})}, "duplicate effective": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{project}, Effective: []DelegatedPolicyGrant{project, project}})}, - "noncanonical closure": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectWrite, Selector: NewSelector(ScopeProjectWrite, "project-one")}}})}, - "unsafe active scope": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeAgentWrite, Selector: NewSelector(ScopeAgentWrite, "agent-one")}}})}, - "malformed known selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}, Effective: []DelegatedPolicyGrant{{Scope: ScopeProjectRead, Selector: Selector{SelectorKeyResourceKind: ResourceKindMCP, SelectorKeyResourceID: "project-one"}}}})}, - "malformed unknown selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}, Effective: []DelegatedPolicyGrant{{Scope: Scope("unknown:scope"), Selector: Selector{SelectorKeyResourceKind: "unknown"}}}})}, + "noncanonical closure": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: authz.ScopeProjectWrite, Selector: authz.NewSelector(authz.ScopeProjectWrite, "project-one")}}, Effective: []DelegatedPolicyGrant{{Scope: authz.ScopeProjectWrite, Selector: authz.NewSelector(authz.ScopeProjectWrite, "project-one")}}})}, + "unsafe active scope": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: authz.ScopeAgentWrite, Selector: authz.NewSelector(authz.ScopeAgentWrite, "agent-one")}}, Effective: []DelegatedPolicyGrant{{Scope: authz.ScopeAgentWrite, Selector: authz.NewSelector(authz.ScopeAgentWrite, "agent-one")}}})}, + "malformed known selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: authz.ScopeProjectRead, Selector: authz.Selector{authz.SelectorKeyResourceKind: authz.ResourceKindMCP, authz.SelectorKeyResourceID: "project-one"}}}, Effective: []DelegatedPolicyGrant{{Scope: authz.ScopeProjectRead, Selector: authz.Selector{authz.SelectorKeyResourceKind: authz.ResourceKindMCP, authz.SelectorKeyResourceID: "project-one"}}}})}, + "malformed unknown selector": {version: 1, raw: mustPolicyJSON(t, DelegatedPolicy{Requested: []DelegatedPolicyGrant{{Scope: authz.Scope("unknown:scope"), Selector: authz.Selector{authz.SelectorKeyResourceKind: "unknown"}}}, Effective: []DelegatedPolicyGrant{{Scope: authz.Scope("unknown:scope"), Selector: authz.Selector{authz.SelectorKeyResourceKind: "unknown"}}}})}, } for name, test := range tests { @@ -115,24 +116,24 @@ func TestDecodeDelegatedPolicyRejectsInvalidProfiles(t *testing.T) { func TestNewDelegatedPolicyRejectsInvalidIssuerInput(t *testing.T) { t.Parallel() - _, err := NewDelegatedPolicyV1([]Grant{NewGrant(ScopeAgentWrite, "agent-one")}) + _, err := NewDelegatedPolicyV1([]authz.Grant{authz.NewGrant(authz.ScopeAgentWrite, "agent-one")}) require.ErrorIs(t, err, ErrInvalidDelegatedPolicy) - grant := NewGrant(ScopeProjectRead, "project-one") - _, err = NewDelegatedPolicyV1([]Grant{grant, grant}) + grant := authz.NewGrant(authz.ScopeProjectRead, "project-one") + _, err = NewDelegatedPolicyV1([]authz.Grant{grant, grant}) require.ErrorIs(t, err, ErrInvalidDelegatedPolicy) } -func delegatedPolicyScopes(grants []DelegatedPolicyGrant) []Scope { - scopes := make([]Scope, len(grants)) +func delegatedPolicyScopes(grants []DelegatedPolicyGrant) []authz.Scope { + scopes := make([]authz.Scope, len(grants)) for i, grant := range grants { scopes[i] = grant.Scope } return scopes } -func grantScopes(grants []Grant) []Scope { - scopes := make([]Scope, len(grants)) +func grantScopes(grants []authz.Grant) []authz.Scope { + scopes := make([]authz.Scope, len(grants)) for i, grant := range grants { scopes[i] = grant.Scope } diff --git a/server/internal/agents/runtimepolicy/setup_test.go b/server/internal/agents/runtimepolicy/setup_test.go new file mode 100644 index 00000000000..db2304d7b42 --- /dev/null +++ b/server/internal/agents/runtimepolicy/setup_test.go @@ -0,0 +1,68 @@ +package runtimepolicy + +import ( + "context" + "log" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + accessrepo "github.com/speakeasy-api/gram/server/internal/access/repo" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/conv" + orgrepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" + "github.com/speakeasy-api/gram/server/internal/testenv" + "github.com/speakeasy-api/gram/server/internal/urn" + "github.com/stretchr/testify/require" +) + +var cloneTestDatabase testenv.PostgresDBCloneFunc + +func TestMain(m *testing.M) { + ctx := context.Background() + container, clone, err := testenv.NewTestPostgres(ctx) + if err != nil { + log.Fatalf("launch test postgres: %v", err) + } + cloneTestDatabase = clone + code := m.Run() + if err := container.Terminate(ctx); err != nil { + log.Fatalf("terminate postgres container: %v", err) + } + os.Exit(code) +} +func newTestDB(t *testing.T) *pgxpool.Pool { + t.Helper() + + conn, err := cloneTestDatabase(t, "testdb") + require.NoError(t, err) + + return conn +} + +func seedOrganization(t *testing.T, ctx context.Context, conn *pgxpool.Pool, organizationID string) { + t.Helper() + + _, err := orgrepo.New(conn).UpsertOrganizationMetadata(ctx, orgrepo.UpsertOrganizationMetadataParams{ + ID: organizationID, + Name: "Test Org", + Slug: organizationID, + WorkosID: conv.PtrToPGText(conv.PtrEmpty("workos-org-" + organizationID)), + }) + require.NoError(t, err) +} + +func seedGrant(t *testing.T, ctx context.Context, conn *pgxpool.Pool, organizationID string, principal urn.Principal, scope authz.Scope, resource string) { + t.Helper() + + selectors, err := authz.NewSelector(scope, resource).MarshalJSON() + require.NoError(t, err) + + _, err = accessrepo.New(conn).UpsertPrincipalGrant(ctx, accessrepo.UpsertPrincipalGrantParams{ + OrganizationID: organizationID, + PrincipalUrn: principal, + Scope: string(scope), + Selectors: selectors, + }) + require.NoError(t, err) +} diff --git a/server/internal/auth/authorize_test.go b/server/internal/auth/authorize_test.go index 40d11c9b691..a8a2cc62269 100644 --- a/server/internal/auth/authorize_test.go +++ b/server/internal/auth/authorize_test.go @@ -12,6 +12,7 @@ import ( accessrepo "github.com/speakeasy-api/gram/server/internal/access/repo" agentsrepo "github.com/speakeasy-api/gram/server/internal/agents/repo" + "github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy" "github.com/speakeasy-api/gram/server/internal/attr" "github.com/speakeasy-api/gram/server/internal/auth" "github.com/speakeasy-api/gram/server/internal/auth/sessions" @@ -259,13 +260,13 @@ func TestAuthorizePrincipalAPIKeyUsesLiveAgentAdmission(t *testing.T) { seedUserProjectGrant(t, ctx, instance, organizationID, ownerUserID, projectID) seedPrincipalProjectGrant(t, ctx, instance, organizationID, urn.NewPrincipal(urn.PrincipalTypeAgent, agent.ID.String()), projectID) - policy, err := authz.NewDelegatedPolicyV1([]authz.Grant{authz.NewGrant(authz.ScopeProjectRead, projectID)}) + policy, err := runtimepolicy.NewDelegatedPolicyV1([]authz.Grant{authz.NewGrant(authz.ScopeProjectRead, projectID)}) require.NoError(t, err) - rawPolicy, err := authz.EncodeDelegatedPolicy(authz.CurrentDelegatedPolicyVersion, policy) + rawPolicy, err := runtimepolicy.EncodeDelegatedPolicy(runtimepolicy.CurrentDelegatedPolicyVersion, policy) require.NoError(t, err) //nolint:glint // notestingrawsql: AIM-194 owns the future principal-key writer; this exercises the loaded-row admission path only _, err = instance.conn.Exec(ctx, `UPDATE api_keys SET scopes = '{}', subject_urn = $1, delegated_grants = $2, delegated_grants_version = $3, expires_at = $4 WHERE key_hash = $5`, - "agent:"+agent.ID.String(), rawPolicy, int32(authz.CurrentDelegatedPolicyVersion), time.Now().Add(24*time.Hour), keyHash) + "agent:"+agent.ID.String(), rawPolicy, int32(runtimepolicy.CurrentDelegatedPolicyVersion), time.Now().Add(24*time.Hour), keyHash) require.NoError(t, err) admitted, err := instance.authorizer.Authorize(ctx, key, apiKeyScheme) diff --git a/server/internal/auth/e2e_test.go b/server/internal/auth/e2e_test.go index a3f7ac1f673..ea0d5a7aa31 100644 --- a/server/internal/auth/e2e_test.go +++ b/server/internal/auth/e2e_test.go @@ -16,6 +16,7 @@ import ( gen "github.com/speakeasy-api/gram/server/gen/auth" accessRepo "github.com/speakeasy-api/gram/server/internal/access/repo" + "github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy" "github.com/speakeasy-api/gram/server/internal/audit" "github.com/speakeasy-api/gram/server/internal/auth" "github.com/speakeasy-api/gram/server/internal/auth/identity" @@ -190,7 +191,7 @@ func newE2EAuthService(t *testing.T, userInfo *MockUserInfo, fetcher *mockWorkOS } nonceStore := cache.NewRedisCacheAdapter(redisClient) - authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient()) + authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient(), authz.EngineOpts{AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential}) trialNotifier := &fakeTrialNotifier{} svc := auth.NewService(logger, tracerProvider, conn, sessionManager, resolver, authConfigs, authzEngine, billingClient, noopCancelScheduler{}, posthogClient, nil, nonceStore, authzProvisioner, productfeatures.SeedOrganizationDefaultsTx, productfeatures.SeedEnterpriseTrialBundleTx, audit.NewLogger(), trialNotifier) diff --git a/server/internal/auth/setup_test.go b/server/internal/auth/setup_test.go index 42085e64348..c7905a6a82e 100644 --- a/server/internal/auth/setup_test.go +++ b/server/internal/auth/setup_test.go @@ -17,6 +17,7 @@ import ( "github.com/workos/workos-go/v6/pkg/usermanagement" gen "github.com/speakeasy-api/gram/server/gen/auth" + "github.com/speakeasy-api/gram/server/internal/agents/runtimepolicy" "github.com/speakeasy-api/gram/server/internal/audit" "github.com/speakeasy-api/gram/server/internal/auth" "github.com/speakeasy-api/gram/server/internal/auth/identity" @@ -205,7 +206,7 @@ func newTestAuthServiceWithWorkOSClient(t *testing.T, userInfo *MockUserInfo, wo } nonceStore := cache.NewRedisCacheAdapter(redisClient) - authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient()) + authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient(), authz.EngineOpts{AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential}) trialNotifier := &fakeTrialNotifier{} svc := auth.NewService(logger, tracerProvider, conn, sessionManager, resolver, authConfigs, authzEngine, billingClient, noopCancelScheduler{}, posthog, nil, nonceStore, authzProvisioner, productfeatures.SeedOrganizationDefaultsTx, productfeatures.SeedEnterpriseTrialBundleTx, audit.NewLogger(), trialNotifier) result := newTestAuthServiceResult(t, svc, conn, sessionManager, resolver, mockServer, authConfigs, nonceStore) @@ -272,7 +273,7 @@ func newTestAuthServiceWithAuthz(t *testing.T, userInfo *MockUserInfo) (context. } nonceStore := cache.NewRedisCacheAdapter(redisClient) - authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient()) + authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient(), authz.EngineOpts{AdmitPrincipalCredential: runtimepolicy.AdmitPrincipalCredential}) trialNotifier := &fakeTrialNotifier{} svc := auth.NewService(logger, tracerProvider, conn, sessionManager, resolver, authConfigs, authzEngine, billingClient, noopCancelScheduler{}, posthog, nil, nonceStore, authzProvisioner, productfeatures.SeedOrganizationDefaultsTx, productfeatures.SeedEnterpriseTrialBundleTx, audit.NewLogger(), trialNotifier) result := newTestAuthServiceResult(t, svc, conn, sessionManager, resolver, mockServer, authConfigs, nonceStore) diff --git a/server/internal/authz/credential_admission.go b/server/internal/authz/credential_admission.go index 311a03c8b80..9d6bb2a8692 100644 --- a/server/internal/authz/credential_admission.go +++ b/server/internal/authz/credential_admission.go @@ -2,116 +2,38 @@ package authz import ( "context" - "errors" - "fmt" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/speakeasy-api/gram/server/internal/agents" + "github.com/jackc/pgx/v5/pgxpool" "github.com/speakeasy-api/gram/server/internal/contextvalues" - keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" - "github.com/speakeasy-api/gram/server/internal/o11y" "github.com/speakeasy-api/gram/server/internal/oops" - "github.com/speakeasy-api/gram/server/internal/urn" ) -// AdmitPrincipalCredential performs authoritative parent admission and loads -// immutable credential policy R, live direct agent policy A, and the current -// owner's live policy O as three independent policy sets. The caller must first -// load and validate the directly active credential row, stamp its immutable -// profile with contextvalues.WithPrincipalCredentialAuthorization, and call -// this method before minting credentials, resolving upstream authority, or -// executing an operation. Successful results must not be cached across requests. -func (e *Engine) AdmitPrincipalCredential(ctx context.Context) (context.Context, error) { - authCtx, ok := contextvalues.GetAuthContext(ctx) - credential, hasCredential := contextvalues.PrincipalCredentialAuthorization(ctx) - actor, hasActor := contextvalues.AuthenticatedActor(ctx) - if !ok || authCtx == nil || !hasCredential || !hasActor || - authCtx.ActiveOrganizationID == "" || credential.AuthorizerUserID == "" || actor.Type != urn.PrincipalTypeAgent { - return ctx, oops.C(oops.CodeUnauthorized) - } - - policy, err := DecodeDelegatedPolicy(DelegatedPolicyVersion(credential.DelegatedGrantsVersion), credential.DelegatedGrants) - if err != nil { - if errors.Is(err, ErrInvalidDelegatedPolicy) { - return ctx, oops.C(oops.CodeUnauthorized) - } - return ctx, fmt.Errorf("decode delegated credential policy: %w", err) - } - - tx, err := e.db.BeginTx(ctx, pgx.TxOptions{ - IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly, DeferrableMode: pgx.NotDeferrable, BeginQuery: "", CommitQuery: "", - }) - if err != nil { - return ctx, fmt.Errorf("begin credential admission snapshot: %w", err) - } - defer o11y.NoLogDefer(func() error { return tx.Rollback(ctx) }) +// PrincipalCredentialAdmission contains independently loaded policy sets from +// authoritative credential admission. Policies must not be cached across requests. +type PrincipalCredentialAdmission struct { + OwnerUserID string + Credential []Grant + Agent []Grant + Owner []Grant +} - if mode, hasMode := contextvalues.APIKeyAuthorization(ctx); hasMode && mode == contextvalues.APIKeyAuthorizationModePrincipal { - apiKeyID, parseErr := uuid.Parse(authCtx.APIKeyID) - if parseErr != nil { - return ctx, oops.C(oops.CodeUnauthorized) - } - _, err = keysrepo.New(tx).GetActivePrincipalAPIKeyForAdmission(ctx, keysrepo.GetActivePrincipalAPIKeyForAdmissionParams{ - ID: apiKeyID, - OrganizationID: authCtx.ActiveOrganizationID, - SubjectUrn: pgtype.Text{String: actor.String(), Valid: true}, - AuthorizerUserID: credential.AuthorizerUserID, - DelegatedGrants: credential.DelegatedGrants, - DelegatedGrantsVersion: pgtype.Int4{Int32: credential.DelegatedGrantsVersion, Valid: true}, - }) - if errors.Is(err, pgx.ErrNoRows) { - return ctx, oops.C(oops.CodeUnauthorized) - } - if err != nil { - return ctx, fmt.Errorf("revalidate principal API key: %w", err) - } - } +// PrincipalCredentialAdmitter supplies application-owned credential admission +// without making the generic authorization engine depend on agent policy. +type PrincipalCredentialAdmitter func(context.Context, *pgxpool.Pool) (PrincipalCredentialAdmission, error) - agent, err := agents.ResolvePrincipal(ctx, tx, authCtx.ActiveOrganizationID, actor) - if err != nil { - if errors.Is(err, agents.ErrPrincipalInvalid) || errors.Is(err, agents.ErrPrincipalNotFound) { - return ctx, oops.C(oops.CodeUnauthorized) - } - return ctx, fmt.Errorf("resolve credential parent: %w", err) - } - if agents.DeriveLifecycle(agent) != agents.LifecycleActive || agent.OwnerReassignmentRequiredAt.Valid { +// AdmitPrincipalCredential fails closed unless application-owned admission is +// configured and succeeds, then preserves R, A, and O as independent policies. +func (e *Engine) AdmitPrincipalCredential(ctx context.Context) (context.Context, error) { + if e.admitPrincipalCredential == nil { return ctx, oops.C(oops.CodeUnauthorized) } - - ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, agent.OwnerUserID) - ownerPrincipals, err := ResolveUserPrincipals(ctx, tx, authCtx.ActiveOrganizationID, agent.OwnerUserID) + admission, err := e.admitPrincipalCredential(ctx, e.db) if err != nil { - if errors.Is(err, ErrPrincipalInvalid) || errors.Is(err, ErrPrincipalNotFound) { - return ctx, oops.C(oops.CodeUnauthorized) - } - return ctx, fmt.Errorf("resolve credential owner: %w", err) + return ctx, err } - ownerEligible := false - for _, principal := range ownerPrincipals { - if principal.String() == ownerPrincipal.String() { - ownerEligible = true - break - } - } - if !ownerEligible { + if admission.OwnerUserID == "" { return ctx, oops.C(oops.CodeUnauthorized) } - - agentPolicy, err := loadResolvedAgentPolicy(ctx, tx, authCtx.ActiveOrganizationID, actor) - if err != nil { - return ctx, fmt.Errorf("load live agent policy: %w", err) - } - ownerPolicy, err := LoadGrants(ctx, tx, authCtx.ActiveOrganizationID, ownerPrincipals) - if err != nil { - return ctx, fmt.Errorf("load live owner policy: %w", err) - } - - if err := tx.Commit(ctx); err != nil { - return ctx, fmt.Errorf("commit credential admission snapshot: %w", err) - } - - ctx = contextvalues.WithPrincipalCredentialOwner(ctx, agent.OwnerUserID) - return principalCredentialPoliciesToContext(ctx, policy.RuntimeGrants(), agentPolicy, ownerPolicy), nil + ctx = contextvalues.WithPrincipalCredentialOwner(ctx, admission.OwnerUserID) + return principalCredentialPoliciesToContext(ctx, admission.Credential, admission.Agent, admission.Owner), nil } diff --git a/server/internal/authz/credential_admission_test.go b/server/internal/authz/credential_admission_test.go index f150610be4a..5a87dd10d67 100644 --- a/server/internal/authz/credential_admission_test.go +++ b/server/internal/authz/credential_admission_test.go @@ -2,316 +2,69 @@ package authz import ( "context" - "sync" + "errors" "testing" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" - agentsrepo "github.com/speakeasy-api/gram/server/internal/agents/repo" "github.com/speakeasy-api/gram/server/internal/contextvalues" - "github.com/speakeasy-api/gram/server/internal/conv" - keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" "github.com/speakeasy-api/gram/server/internal/oops" - orgrepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" "github.com/speakeasy-api/gram/server/internal/testenv" "github.com/speakeasy-api/gram/server/internal/thirdparty/workos" "github.com/speakeasy-api/gram/server/internal/urn" - usersrepo "github.com/speakeasy-api/gram/server/internal/users/repo" ) -func TestPrincipalCredentialAdmissionRejectsEachParentGate(t *testing.T) { +func TestCredentialAdmissionHookFailsClosed(t *testing.T) { t.Parallel() - - mutations := map[string]func(t *testing.T, fixture credentialAdmissionFixture){ - "suspended agent": func(t *testing.T, fixture credentialAdmissionFixture) { - t.Helper() - _, err := agentsrepo.New(fixture.db).SuspendAgent(t.Context(), agentsrepo.SuspendAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) - require.NoError(t, err) - }, - "revoked agent": func(t *testing.T, fixture credentialAdmissionFixture) { - t.Helper() - _, err := agentsrepo.New(fixture.db).RevokeAgent(t.Context(), agentsrepo.RevokeAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) - require.NoError(t, err) - }, - "deleted agent": func(t *testing.T, fixture credentialAdmissionFixture) { - t.Helper() - _, err := agentsrepo.New(fixture.db).DeleteAgent(t.Context(), agentsrepo.DeleteAgentParams{OrganizationID: fixture.organizationID, ID: fixture.agentID}) - require.NoError(t, err) + admissionError := errors.New("admission failed") + tests := map[string]PrincipalCredentialAdmitter{ + "unconfigured": nil, + "missing owner": func(context.Context, *pgxpool.Pool) (PrincipalCredentialAdmission, error) { + return PrincipalCredentialAdmission{}, nil }, - "owner reassignment required": func(t *testing.T, fixture credentialAdmissionFixture) { - t.Helper() - _, err := agentsrepo.New(fixture.db).LatchAgentsForOwnerLossByMembership(t.Context(), agentsrepo.LatchAgentsForOwnerLossByMembershipParams{ - OwnerReassignmentReason: pgtype.Text{String: "membership_loss", Valid: true}, - OrganizationID: fixture.organizationID, - OwnerUserID: fixture.ownerUserID, - }) - require.NoError(t, err) + "failed admission": func(context.Context, *pgxpool.Pool) (PrincipalCredentialAdmission, error) { + return PrincipalCredentialAdmission{}, admissionError }, - "owner ineligible": func(t *testing.T, fixture credentialAdmissionFixture) { - t.Helper() - err := orgrepo.New(fixture.db).DeleteOrganizationUserRelationship(t.Context(), orgrepo.DeleteOrganizationUserRelationshipParams{ - OrganizationID: fixture.organizationID, - UserID: conv.ToPGText(fixture.ownerUserID), - }) - require.NoError(t, err) - }, - } - - for name, mutate := range mutations { - t.Run(name, func(t *testing.T) { - t.Parallel() - fixture := newCredentialAdmissionFixture(t) - mutate(t, fixture) - _, err := fixture.engine.PrepareContext(fixture.requestContext) - requireUnauthorized(t, err) - }) } -} - -func TestPrincipalAPIKeyAdmissionRevalidatesCredentialActivity(t *testing.T) { - t.Parallel() - - for name, mutate := range map[string]func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID){ - "deleted": func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID) { - t.Helper() - _, err := keysrepo.New(fixture.db).DeleteAPIKey(t.Context(), keysrepo.DeleteAPIKeyParams{ID: keyID, OrganizationID: fixture.organizationID}) - require.NoError(t, err) - }, - "expired": func(t *testing.T, fixture credentialAdmissionFixture, keyID uuid.UUID) { - t.Helper() - //nolint:glint // notestingrawsql: simulate expiry after the credential profile was loaded - _, err := fixture.db.Exec(t.Context(), `UPDATE api_keys SET expires_at = statement_timestamp() - INTERVAL '1 second' WHERE id = $1 AND organization_id = $2`, keyID, fixture.organizationID) - require.NoError(t, err) - }, - } { - t.Run(name, func(t *testing.T) { - t.Parallel() - fixture := newCredentialAdmissionFixture(t) - authCtx, ok := contextvalues.GetAuthContext(fixture.requestContext) - require.True(t, ok) - actor, ok := contextvalues.AuthenticatedActor(fixture.requestContext) - require.True(t, ok) - credential, ok := contextvalues.PrincipalCredentialAuthorization(fixture.requestContext) - require.True(t, ok) - - keyID := uuid.New() - keyHash := uuid.NewString() - created, err := keysrepo.New(fixture.db).CreateAPIKey(t.Context(), keysrepo.CreateAPIKeyParams{ - OrganizationID: fixture.organizationID, CreatedByUserID: fixture.authorizerUserID, - Name: "admission-" + keyID.String(), KeyPrefix: "gram_test", KeyHash: keyHash, Scopes: []string{"producer"}, - }) - require.NoError(t, err) - //nolint:glint // notestingrawsql: AIM-194 owns the principal-key writer; this seeds its immutable profile - _, err = fixture.db.Exec(t.Context(), `UPDATE api_keys SET scopes = '{}', subject_urn = $1, delegated_grants = $2, delegated_grants_version = $3, expires_at = statement_timestamp() + INTERVAL '1 day' WHERE id = $4`, - actor.String(), credential.DelegatedGrants, credential.DelegatedGrantsVersion, created.ID) - require.NoError(t, err) - - keyAuth := *authCtx - keyAuth.APIKeyID = created.ID.String() - requestContext := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), &keyAuth, actor, credential) - _, err = fixture.engine.PrepareContext(requestContext) - require.NoError(t, err) - - mutate(t, fixture, created.ID) - _, err = fixture.engine.PrepareContext(requestContext) - requireUnauthorized(t, err) - }) - } -} - -func TestPrincipalCredentialAdmissionIsTenantBound(t *testing.T) { - t.Parallel() - - fixture := newCredentialAdmissionFixture(t) - otherOrganizationID := "org-admission-other-" + uuid.NewString() - seedOrganization(t, t.Context(), fixture.db, otherOrganizationID) - authCtx, ok := contextvalues.GetAuthContext(fixture.requestContext) - require.True(t, ok) - crossTenant := *authCtx - crossTenant.ActiveOrganizationID = otherOrganizationID - credential, ok := contextvalues.PrincipalCredentialAuthorization(fixture.requestContext) - require.True(t, ok) - actor, ok := contextvalues.AuthenticatedActor(fixture.requestContext) - require.True(t, ok) - ctx := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &crossTenant, actor, credential) - - _, err := fixture.engine.PrepareContext(ctx) - requireUnauthorized(t, err) -} - -func TestPrincipalCredentialAdmissionReloadsLivePolicies(t *testing.T) { - t.Parallel() - - fixture := newCredentialAdmissionFixture(t) - check := Check{Scope: ScopeProjectRead, ResourceID: fixture.projectID} - - prepared, err := fixture.engine.PrepareContext(fixture.requestContext) - require.NoError(t, err) - require.NoError(t, fixture.engine.Require(prepared, check)) - actor, ok := contextvalues.AuthenticatedActor(prepared) - require.True(t, ok) - require.Equal(t, "agent:"+fixture.agentID.String(), actor.String()) - authorizer, owner, ok := contextvalues.PrincipalCredentialProvenance(prepared) - require.True(t, ok) - require.Equal(t, fixture.authorizerUserID, authorizer) - require.Equal(t, fixture.ownerUserID, owner) - - for name, principalURN := range map[string]func(credentialAdmissionFixture) string{ - "agent A": func(f credentialAdmissionFixture) string { return "agent:" + f.agentID.String() }, - "owner O": func(f credentialAdmissionFixture) string { return "user:" + f.ownerUserID }, - } { + for name, admit := range tests { t.Run(name, func(t *testing.T) { t.Parallel() - fixture := newCredentialAdmissionFixture(t) - check := Check{Scope: ScopeProjectRead, ResourceID: fixture.projectID} - principal := principalURN(fixture) - //nolint:glint // notestingrawsql: simulate an authoritative live-policy removal and restoration - _, err := fixture.db.Exec(t.Context(), `DELETE FROM principal_grants WHERE organization_id = $1 AND principal_urn = $2 AND scope = $3`, fixture.organizationID, principal, string(ScopeProjectRead)) - require.NoError(t, err) - - prepared, err := fixture.engine.PrepareContext(fixture.requestContext) - require.NoError(t, err) + engine := NewEngine(testenv.NewLogger(t), nil, func(context.Context, string) (bool, error) { return false, nil }, workos.NewStubClient(), EngineOpts{AdmitPrincipalCredential: admit}) + _, err := engine.AdmitPrincipalCredential(t.Context()) + if name == "failed admission" { + require.ErrorIs(t, err, admissionError) + return + } var denied *oops.ShareableError - require.ErrorAs(t, fixture.engine.Require(prepared, check), &denied) - require.Equal(t, oops.CodeForbidden, denied.Code) - - parsed, err := urn.ParsePrincipal(principal) - require.NoError(t, err) - seedGrant(t, t.Context(), fixture.db, fixture.organizationID, parsed, ScopeProjectRead, fixture.projectID) - prepared, err = fixture.engine.PrepareContext(fixture.requestContext) - require.NoError(t, err) - require.NoError(t, fixture.engine.Require(prepared, check), "restoration may reactivate authority still present in immutable R") + require.ErrorAs(t, err, &denied) + require.Equal(t, oops.CodeUnauthorized, denied.Code) }) } } -func TestPrincipalCredentialAdmissionUsesOnlyCurrentOwnerAfterTransfer(t *testing.T) { +func TestCredentialAdmissionHookReloadsAndConjoinsPolicies(t *testing.T) { t.Parallel() - - fixture := newCredentialAdmissionFixture(t) - newOwnerUserID := "new-owner-" + uuid.NewString() - _, err := usersrepo.New(fixture.db).UpsertUser(t.Context(), usersrepo.UpsertUserParams{ - ID: newOwnerUserID, Email: newOwnerUserID + "@example.com", DisplayName: newOwnerUserID, PhotoUrl: conv.PtrToPGText(nil), Admin: false, - }) - require.NoError(t, err) - _, err = orgrepo.New(fixture.db).UpsertOrganizationUserRelationship(t.Context(), orgrepo.UpsertOrganizationUserRelationshipParams{ - OrganizationID: fixture.organizationID, UserID: conv.ToPGText(newOwnerUserID), - }) - require.NoError(t, err) - _, err = agentsrepo.New(fixture.db).TransferAgent(t.Context(), agentsrepo.TransferAgentParams{ - OwnerUserID: newOwnerUserID, OrganizationID: fixture.organizationID, ID: fixture.agentID, - }) - require.NoError(t, err) - - check := Check{Scope: ScopeProjectRead, ResourceID: fixture.projectID} - prepared, err := fixture.engine.PrepareContext(fixture.requestContext) - require.NoError(t, err) - var denied *oops.ShareableError - require.ErrorAs(t, fixture.engine.Require(prepared, check), &denied, "former owner's grants cannot authorize after transfer") - require.Equal(t, oops.CodeForbidden, denied.Code) - - seedGrant(t, t.Context(), fixture.db, fixture.organizationID, urn.NewPrincipal(urn.PrincipalTypeUser, newOwnerUserID), ScopeProjectRead, fixture.projectID) - prepared, err = fixture.engine.PrepareContext(fixture.requestContext) + calls := 0 + grants := []Grant{NewGrant(ScopeProjectRead, "project-one")} + admit := func(context.Context, *pgxpool.Pool) (PrincipalCredentialAdmission, error) { + calls++ + agent := grants + if calls > 1 { + agent = nil + } + return PrincipalCredentialAdmission{OwnerUserID: "current-owner", Credential: grants, Agent: agent, Owner: grants}, nil + } + engine := NewEngine(testenv.NewLogger(t), nil, func(context.Context, string) (bool, error) { return false, nil }, workos.NewStubClient(), EngineOpts{AdmitPrincipalCredential: admit}) + ctx := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &contextvalues.AuthContext{ActiveOrganizationID: "org-test"}, urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001"), contextvalues.PrincipalCredential{}) + prepared, err := engine.PrepareContext(ctx) require.NoError(t, err) - require.NoError(t, fixture.engine.Require(prepared, check)) + require.NoError(t, engine.Require(prepared, Check{Scope: ScopeProjectRead, ResourceID: "project-one"})) _, owner, ok := contextvalues.PrincipalCredentialProvenance(prepared) require.True(t, ok) - require.Equal(t, newOwnerUserID, owner) -} - -func TestPrincipalCredentialAdmissionDeniesEveryCheckAfterRevocationCommit(t *testing.T) { - t.Parallel() - - fixture := newCredentialAdmissionFixture(t) - start := make(chan struct{}) - results := make(chan error, 32) - var workers sync.WaitGroup - for range 32 { - workers.Go(func() { - <-start - _, err := fixture.engine.PrepareContext(fixture.requestContext) - results <- err - }) - } - - _, err := agentsrepo.New(fixture.db).RevokeAgent(t.Context(), agentsrepo.RevokeAgentParams{ - OrganizationID: fixture.organizationID, - ID: fixture.agentID, - }) + require.Equal(t, "current-owner", owner) + prepared, err = engine.PrepareContext(prepared) require.NoError(t, err) - close(start) - workers.Wait() - close(results) - for err := range results { - requireUnauthorized(t, err) - } -} - -type credentialAdmissionFixture struct { - db *pgxpool.Pool - engine *Engine - requestContext context.Context //nolint:containedctx // immutable request authentication fixture - organizationID string - ownerUserID string - authorizerUserID string - agentID uuid.UUID - projectID string -} - -func newCredentialAdmissionFixture(t *testing.T) credentialAdmissionFixture { - t.Helper() - ctx := t.Context() - db := newTestDB(t) - organizationID := "org-admission-" + uuid.NewString() - ownerUserID := "owner-" + uuid.NewString() - authorizerUserID := "authorizer-" + uuid.NewString() - projectID := "project-" + uuid.NewString() - seedOrganization(t, ctx, db, organizationID) - - for _, userID := range []string{ownerUserID, authorizerUserID} { - _, err := usersrepo.New(db).UpsertUser(ctx, usersrepo.UpsertUserParams{ - ID: userID, Email: userID + "@example.com", DisplayName: userID, PhotoUrl: conv.PtrToPGText(nil), Admin: false, - }) - require.NoError(t, err) - _, err = orgrepo.New(db).UpsertOrganizationUserRelationship(ctx, orgrepo.UpsertOrganizationUserRelationshipParams{ - OrganizationID: organizationID, UserID: conv.ToPGText(userID), - }) - require.NoError(t, err) - } - - agent, err := agentsrepo.New(db).CreateAgent(ctx, agentsrepo.CreateAgentParams{ - OrganizationID: organizationID, OwnerUserID: ownerUserID, Name: "Credential admission agent", - }) - require.NoError(t, err) - agentPrincipal := urn.NewPrincipal(urn.PrincipalTypeAgent, agent.ID.String()) - ownerPrincipal := urn.NewPrincipal(urn.PrincipalTypeUser, ownerUserID) - seedGrant(t, ctx, db, organizationID, agentPrincipal, ScopeProjectRead, projectID) - seedGrant(t, ctx, db, organizationID, ownerPrincipal, ScopeProjectRead, projectID) - - policy, err := NewDelegatedPolicyV1([]Grant{NewGrant(ScopeProjectRead, projectID)}) - require.NoError(t, err) - rawPolicy, err := EncodeDelegatedPolicy(CurrentDelegatedPolicyVersion, policy) - require.NoError(t, err) - requestContext := contextvalues.WithPrincipalCredentialAuthorization(ctx, &contextvalues.AuthContext{ - ActiveOrganizationID: organizationID, - }, agentPrincipal, contextvalues.PrincipalCredential{ - AuthorizerUserID: authorizerUserID, - DelegatedGrants: rawPolicy, - DelegatedGrantsVersion: int32(CurrentDelegatedPolicyVersion), - }) - - return credentialAdmissionFixture{ - db: db, engine: NewEngine(testenv.NewLogger(t), db, staticChallengeLogging(false), workos.NewStubClient()), requestContext: requestContext, - organizationID: organizationID, ownerUserID: ownerUserID, authorizerUserID: authorizerUserID, agentID: agent.ID, projectID: projectID, - } -} - -func requireUnauthorized(t *testing.T, err error) { - t.Helper() - var oopsErr *oops.ShareableError - require.ErrorAs(t, err, &oopsErr) - require.Equal(t, oops.CodeUnauthorized, oopsErr.Code) + require.Equal(t, 2, calls, "even a prepared context must repeat admission") + require.Error(t, engine.Require(prepared, Check{Scope: ScopeProjectRead, ResourceID: "project-one"}), "empty live agent policy must not inherit credential or owner authority") } diff --git a/server/internal/authz/engine.go b/server/internal/authz/engine.go index e5aafcd429f..aa380b23a50 100644 --- a/server/internal/authz/engine.go +++ b/server/internal/authz/engine.go @@ -22,6 +22,9 @@ type MembershipFetcher interface { type EngineOpts struct { DevMode bool + // AdmitPrincipalCredential must be configured to accept principal-backed credentials. + // Omission disables them rather than falling back to user authorization. + AdmitPrincipalCredential PrincipalCredentialAdmitter } // ChallengeLoggingEnabled checks whether authz challenge logging to ClickHouse @@ -29,11 +32,12 @@ type EngineOpts struct { type ChallengeLoggingEnabled func(ctx context.Context, organizationID string) (bool, error) type Engine struct { - logger *slog.Logger - db *pgxpool.Pool - challengeLoggingEnabled ChallengeLoggingEnabled - isDev bool - membership MembershipFetcher + admitPrincipalCredential PrincipalCredentialAdmitter + logger *slog.Logger + db *pgxpool.Pool + challengeLoggingEnabled ChallengeLoggingEnabled + isDev bool + membership MembershipFetcher } func NewEngine( @@ -44,18 +48,21 @@ func NewEngine( opts ...EngineOpts, ) *Engine { var devMode bool + var admitPrincipalCredential PrincipalCredentialAdmitter if len(opts) > 0 { devMode = opts[0].DevMode + admitPrincipalCredential = opts[0].AdmitPrincipalCredential } authzLogger := logger.With(attr.SlogComponent("authz")) return &Engine{ - logger: authzLogger, - db: db, - challengeLoggingEnabled: challengeLogging, - isDev: devMode, - membership: membership, + admitPrincipalCredential: admitPrincipalCredential, + logger: authzLogger, + db: db, + challengeLoggingEnabled: challengeLogging, + isDev: devMode, + membership: membership, } } From 81ac9b9f128b2dadf2ec4e001ad8cb925df0f510 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 8 Sep 2026 23:39:45 +0100 Subject: [PATCH 07/12] fix: wrap delegated policy selector validation errors --- server/internal/agents/runtimepolicy/delegated_policy.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/internal/agents/runtimepolicy/delegated_policy.go b/server/internal/agents/runtimepolicy/delegated_policy.go index 6c50f03e86a..79344ddc46f 100644 --- a/server/internal/agents/runtimepolicy/delegated_policy.go +++ b/server/internal/agents/runtimepolicy/delegated_policy.go @@ -223,7 +223,10 @@ func validateStoredDelegatedPolicyGrant(version DelegatedPolicyVersion, grant De if definition.safeSince == 0 || definition.safeSince > RuntimeScopeRegistryVersion(version) { return fmt.Errorf("scope %q is not agent-runtime-safe", grant.Scope) } - return authz.ValidateSelector(grant.Scope, grant.Selector) + if err := authz.ValidateSelector(grant.Scope, grant.Selector); err != nil { + return fmt.Errorf("validate delegated policy selector: %w", err) + } + return nil } func delegatedPolicyClosure(requested []DelegatedPolicyGrant) ([]DelegatedPolicyGrant, error) { From 4005ca57f0205d653cc1f45c825523a9183d042b Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 8 Sep 2026 23:42:43 +0100 Subject: [PATCH 08/12] fix: stabilize shared concurrent server checks --- .../ai_integration_usage_poller_test.go | 16 +++++++ server/internal/killswitches/queries_test.go | 7 ++- .../mcp/authnchallenge_token_refresh_test.go | 48 ++++++++++++++++--- .../internal/usage/create_stripe_checkout.go | 6 +-- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/server/internal/background/ai_integration_usage_poller_test.go b/server/internal/background/ai_integration_usage_poller_test.go index 8f88a9fdbdd..0b2ed133847 100644 --- a/server/internal/background/ai_integration_usage_poller_test.go +++ b/server/internal/background/ai_integration_usage_poller_test.go @@ -3,6 +3,7 @@ package background import ( "context" "errors" + "sync" "testing" "time" @@ -155,9 +156,12 @@ func TestAIUsagePollerCoordinatorWorkflowListsCandidatesAndStartsChildren(t *tes activity.RegisterOptions{Name: "GetAIIntegrationsCandidates"}, ) + var syncedMu sync.Mutex var synced []string env.RegisterActivityWithOptions( func(_ context.Context, input string) error { + syncedMu.Lock() + defer syncedMu.Unlock() synced = append(synced, input) return nil }, @@ -169,6 +173,8 @@ func TestAIUsagePollerCoordinatorWorkflowListsCandidatesAndStartsChildren(t *tes require.True(t, env.IsWorkflowCompleted()) require.NoError(t, env.GetWorkflowError()) require.Equal(t, 3, listCalls) + syncedMu.Lock() + defer syncedMu.Unlock() require.ElementsMatch(t, candidateSyncIDs(candidates), synced) } @@ -229,9 +235,12 @@ func TestAIUsagePollerCoordinatorWorkflowContinuesAfterChildFailure(t *testing.T ) attemptsByConfigID := map[string]int{} + var syncedMu sync.Mutex var synced []string env.RegisterActivityWithOptions( func(_ context.Context, input string) error { + syncedMu.Lock() + defer syncedMu.Unlock() attemptsByConfigID[input]++ if input == failedCandidate.SyncID.String() { return errors.New("cursor API unavailable") @@ -247,6 +256,8 @@ func TestAIUsagePollerCoordinatorWorkflowContinuesAfterChildFailure(t *testing.T require.True(t, env.IsWorkflowCompleted()) require.NoError(t, env.GetWorkflowError()) require.Equal(t, 3, listCalls) + syncedMu.Lock() + defer syncedMu.Unlock() require.Equal(t, activities.PollUsageMaxAttempts, attemptsByConfigID[failedCandidate.SyncID.String()]) require.ElementsMatch(t, []string{successCandidate.SyncID.String(), nextBatchCandidate.SyncID.String()}, synced) } @@ -291,9 +302,12 @@ func TestAIUsagePollerCoordinatorStartsIndependentWorkflowsForConfigSchedules(t activity.RegisterOptions{Name: "GetAIIntegrationsCandidates"}, ) + var syncedMu sync.Mutex var synced []string env.RegisterActivityWithOptions( func(_ context.Context, input string) error { + syncedMu.Lock() + defer syncedMu.Unlock() synced = append(synced, input) return nil }, @@ -304,6 +318,8 @@ func TestAIUsagePollerCoordinatorStartsIndependentWorkflowsForConfigSchedules(t require.True(t, env.IsWorkflowCompleted()) require.NoError(t, env.GetWorkflowError()) + syncedMu.Lock() + defer syncedMu.Unlock() require.ElementsMatch(t, candidateSyncIDs(candidates), synced) } diff --git a/server/internal/killswitches/queries_test.go b/server/internal/killswitches/queries_test.go index a8886f19a3d..b2dbb47cdf2 100644 --- a/server/internal/killswitches/queries_test.go +++ b/server/internal/killswitches/queries_test.go @@ -267,7 +267,12 @@ func TestEvaluateCurrentPrescriptionsRepresentativePlan(t *testing.T) { estimatedRows, err := strconv.Atoi(estimateMatch[1]) require.NoError(t, err) require.Equal(t, 1, estimatedRows) - require.Contains(t, plan, "killswitch_prescriptions_evaluator_idx") + // With one principal per organization, the organization/id index is also + // selective. PostgreSQL may choose either index as costs and statistics vary. + require.True(t, + strings.Contains(plan, "killswitch_prescriptions_evaluator_idx") || + strings.Contains(plan, "killswitch_prescriptions_organization_id_id_key"), + "expected an indexed prescription lookup:\n%s", plan) require.Contains(t, plan, "killswitch_prescription_version_resources_lookup_idx") } diff --git a/server/internal/mcp/authnchallenge_token_refresh_test.go b/server/internal/mcp/authnchallenge_token_refresh_test.go index fcd928701de..36d3883d6dc 100644 --- a/server/internal/mcp/authnchallenge_token_refresh_test.go +++ b/server/internal/mcp/authnchallenge_token_refresh_test.go @@ -178,15 +178,21 @@ func TestHandleToken_RefreshReplayCacheErrorIsRetryable(t *testing.T) { func TestHandleToken_RefreshReplayRequestDeadlineIsRetryable(t *testing.T) { t.Parallel() - ctx, ti := newTestMCPService(t) - toolset, issuer, client, refreshToken := seedRefreshReplaySession(t, ctx, ti) - _, lockKey := refreshReplayKeys(issuer.ID, refreshToken) - claimed, err := ti.cacheAdapter.Add(ctx, lockKey, 30*time.Second) - require.NoError(t, err) - require.True(t, claimed) + var cancel context.CancelFunc + ctx, ti := newTestMCPServiceWithCacheWrapper(t, func(delegate cache.Cache) cache.Cache { + return deadlineOnLeaseCache{ + contendedLeaseCache: contendedLeaseCache{Cache: delegate}, + expire: func() { cancel() }, + } + }) + toolset, _, client, refreshToken := seedRefreshReplaySession(t, ctx, ti) - deadlineCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + requestCtx, requestCancel := context.WithCancel(ctx) + cancel = requestCancel defer cancel() + // Expire only once the request reaches lease contention, not during the + // database and cache reads that precede the replay wait on a busy runner. + deadlineCtx := refreshDeadlineContext{Context: requestCtx} result := performRefreshRequest(deadlineCtx, ti, toolset.McpSlug.String, client.ClientID, refreshToken) require.NoError(t, result.err) require.Equal(t, http.StatusServiceUnavailable, result.code, result.body) @@ -384,6 +390,34 @@ func (failingConditionalCache) SetIfAbsent(context.Context, string, any, time.Du return false, errors.New("refresh replay publication unavailable") } +// refreshDeadlineContext lets the test trigger a deadline without a wall-clock race. +type refreshDeadlineContext struct { + context.Context +} + +func (c refreshDeadlineContext) Err() error { + if c.Context.Err() != nil { + return context.DeadlineExceeded + } + return nil +} + +type deadlineOnLeaseCache struct { + contendedLeaseCache + expire context.CancelFunc +} + +func (c deadlineOnLeaseCache) Get(ctx context.Context, key string, value any) error { + // Keep replay reads available after expiry so the request exercises the + // deadline branch rather than the cache-unavailable response. + return c.Cache.Get(context.WithoutCancel(ctx), key, value) +} + +func (c deadlineOnLeaseCache) AcquireLease(context.Context, string, string, time.Duration) (bool, error) { + c.expire() + return false, nil +} + type contendedLeaseCache struct { cache.Cache } diff --git a/server/internal/usage/create_stripe_checkout.go b/server/internal/usage/create_stripe_checkout.go index bdb5ee0ea7e..9c92ccf5ca3 100644 --- a/server/internal/usage/create_stripe_checkout.go +++ b/server/internal/usage/create_stripe_checkout.go @@ -246,7 +246,7 @@ func (s *Service) CreateStripeCheckout(ctx context.Context, _ *gen.CreateStripeC convertedTrial, err := s.convertEnterpriseTrialForCheckoutTx(ctx, dbtx, authCtx.ActiveOrganizationID, expectedTrial, checkoutIntentTrialFingerprint(preparedIntent.idempotencyKey), checkout.ID) if err != nil { - if errors.Is(err, errStripeCheckoutTrialLifecycleChanged) { + if errors.Is(err, errStripeCheckoutTrialLifecycleChanged) || isStripeCheckoutCASConflict(err) { return "", oops.E(oops.CodeConflict, err, "trial lifecycle changed while Stripe Checkout was being created").LogWarn(ctx, s.logger) } return "", oops.E(oops.CodeUnexpected, err, "failed to convert enterprise trial during Stripe Checkout").LogError(ctx, s.logger) @@ -262,8 +262,8 @@ func (s *Service) CreateStripeCheckout(ctx context.Context, _ *gen.CreateStripeC StripeCheckoutExpiresAt: finiteTimestamptz(preparedIntent.expiresAt), }) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return "", oops.E(oops.CodeConflict, nil, "billing state changed while Checkout was being created").LogWarn(ctx, s.logger) + if errors.Is(err, pgx.ErrNoRows) || isStripeCheckoutCASConflict(err) { + return "", oops.E(oops.CodeConflict, err, "billing state changed while Checkout was being created").LogWarn(ctx, s.logger) } return "", oops.E(oops.CodeUnexpected, err, "failed to finalize Stripe Checkout").LogError(ctx, s.logger) } From cd8a44b7c1e81ed167e7d7bacd16e61b4c86cb63 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 8 Sep 2026 23:54:05 +0100 Subject: [PATCH 09/12] fix: satisfy deadline replay fixture lint rules --- server/internal/mcp/authnchallenge_token_refresh_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/internal/mcp/authnchallenge_token_refresh_test.go b/server/internal/mcp/authnchallenge_token_refresh_test.go index 36d3883d6dc..8cb582b6268 100644 --- a/server/internal/mcp/authnchallenge_token_refresh_test.go +++ b/server/internal/mcp/authnchallenge_token_refresh_test.go @@ -392,7 +392,7 @@ func (failingConditionalCache) SetIfAbsent(context.Context, string, any, time.Du // refreshDeadlineContext lets the test trigger a deadline without a wall-clock race. type refreshDeadlineContext struct { - context.Context + context.Context //nolint:containedctx // This context decorator controls deadline expiry in the test. } func (c refreshDeadlineContext) Err() error { @@ -410,7 +410,10 @@ type deadlineOnLeaseCache struct { func (c deadlineOnLeaseCache) Get(ctx context.Context, key string, value any) error { // Keep replay reads available after expiry so the request exercises the // deadline branch rather than the cache-unavailable response. - return c.Cache.Get(context.WithoutCancel(ctx), key, value) + if err := c.Cache.Get(context.WithoutCancel(ctx), key, value); err != nil { + return fmt.Errorf("get cached refresh replay: %w", err) + } + return nil } func (c deadlineOnLeaseCache) AcquireLease(context.Context, string, string, time.Duration) (bool, error) { From 05e385b6bced94719fcc8ee0b29b966c5eb70bfa Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 9 Sep 2026 13:48:15 +0100 Subject: [PATCH 10/12] fix: preserve MCP principal credential admission errors --- .../mcp/credential_admission_internal_test.go | 119 ++++++++++++++++++ server/internal/mcp/impl.go | 8 +- server/internal/mcp/serveendpoint.go | 7 +- 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 server/internal/mcp/credential_admission_internal_test.go diff --git a/server/internal/mcp/credential_admission_internal_test.go b/server/internal/mcp/credential_admission_internal_test.go new file mode 100644 index 00000000000..aaac829c95a --- /dev/null +++ b/server/internal/mcp/credential_admission_internal_test.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + + authrepo "github.com/speakeasy-api/gram/server/internal/auth/repo" + "github.com/speakeasy-api/gram/server/internal/authz" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/mcpservers" + mcpserversrepo "github.com/speakeasy-api/gram/server/internal/mcpservers/repo" + "github.com/speakeasy-api/gram/server/internal/oops" + "github.com/speakeasy-api/gram/server/internal/thirdparty/workos" + toolsetsrepo "github.com/speakeasy-api/gram/server/internal/toolsets/repo" + "github.com/speakeasy-api/gram/server/internal/urn" +) + +func TestMCPPrincipalCredentialReadmissionErrors(t *testing.T) { + t.Parallel() + for _, caller := range []string{"hosted", "proxy"} { + for _, tt := range []struct { + name string + admissionErr error + status int + }{ + {"revoked", fmt.Errorf("credential revoked: %w", oops.C(oops.CodeUnauthorized)), http.StatusUnauthorized}, + {"unexpected", errors.New("admission backend unavailable"), http.StatusInternalServerError}, + {"unexpected shareable", oops.E(oops.CodeUnexpected, nil, "admission backend unavailable"), http.StatusInternalServerError}, + } { + t.Run(caller+"/"+tt.name, func(t *testing.T) { + t.Parallel() + var logs bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&logs, nil)) + calls := 0 + engine := authz.NewEngine(logger, nil, func(context.Context, string) (bool, error) { return false, nil }, workos.NewStubClient(), authz.EngineOpts{ + AdmitPrincipalCredential: func(context.Context, *pgxpool.Pool) (authz.PrincipalCredentialAdmission, error) { + calls++ + if calls == 1 { + return authz.PrincipalCredentialAdmission{OwnerUserID: "test-owner"}, nil + } + return authz.PrincipalCredentialAdmission{}, tt.admissionErr + }, + }) + ctx := contextvalues.WithPrincipalCredentialAuthorization(t.Context(), &contextvalues.AuthContext{ActiveOrganizationID: "org-test"}, urn.NewPrincipal(urn.PrincipalTypeAgent, uuid.NewString()), contextvalues.PrincipalCredential{}) + // Identity authentication already admitted this credential. The MCP caller + // must still re-admit it and preserve a subsequent live denial as a 401. + ctx, err := engine.PrepareContext(ctx) + require.NoError(t, err) + projectID := uuid.New() + serverURL, err := url.Parse("https://example.com") + require.NoError(t, err) + service := &Service{logger: logger, authz: engine, serverURL: serverURL, + authRepo: authrepo.New(&admissionProjectsDB{projectID: projectID}), + } + switch caller { + case "hosted": + req := httptest.NewRequest(http.MethodPost, "/mcp/test", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)).WithContext(ctx) + err = service.serveToolsetResolved(httptest.NewRecorder(), req, &toolsetsrepo.Toolset{ID: uuid.New(), ProjectID: projectID}, "test", "mcp", &hostedServing{callerGated: true}, nil, nil, nil) + case "proxy": + _, err = service.authorizeProxyBackendAccess(ctx, logger, projectID, &mcpserversrepo.McpServer{ID: uuid.New(), Visibility: mcpservers.VisibilityPrivate}) + } + require.Equal(t, 2, calls) + require.ErrorIs(t, err, tt.admissionErr) + var shareable *oops.ShareableError + require.ErrorAs(t, err, &shareable) + require.Equal(t, tt.status, shareable.HTTPStatus(ctx)) + if tt.status == http.StatusInternalServerError { + require.Contains(t, logs.String(), `"level":"ERROR"`) + } else { + require.Empty(t, logs.String(), "expected denial must not be logged as an unexpected failure") + } + }) + } + } +} + +// The hosted caller checks organization project membership before admission. +// Supply that one result without requiring a database for error classification. +type admissionProjectsDB struct { + authrepo.DBTX + projectID uuid.UUID +} + +func (db *admissionProjectsDB) Query(context.Context, string, ...any) (pgx.Rows, error) { + return &admissionProjectRows{projectID: db.projectID}, nil +} + +type admissionProjectRows struct { + pgx.Rows + projectID uuid.UUID + read bool +} + +func (r *admissionProjectRows) Next() bool { + if r.read { + return false + } + r.read = true + return true +} +func (r *admissionProjectRows) Scan(dest ...any) error { + *dest[0].(*uuid.UUID) = r.projectID + return nil +} +func (*admissionProjectRows) Close() {} +func (*admissionProjectRows) Err() error { return nil } diff --git a/server/internal/mcp/impl.go b/server/internal/mcp/impl.go index 062043d08ea..30bfbc0d436 100644 --- a/server/internal/mcp/impl.go +++ b/server/internal/mcp/impl.go @@ -1056,10 +1056,14 @@ func (s *Service) serveToolsetResolved(w http.ResponseWriter, r *http.Request, t // Public MCPs are open to everyone — no RBAC enforcement. if !cfg.isPublic { // Ensure grants are loaded — not all auth strategies in authenticateToken - // go through auth.Authorize (which calls PrepareContext). This is a no-op - // if grants are already in context. + // go through auth.Authorize (which calls PrepareContext). Principal + // credentials repeat live admission even when grants are already loaded. ctx, err = s.authz.PrepareContext(ctx) if err != nil { + var shareable *oops.ShareableError + if errors.As(err, &shareable) && shareable.Code != oops.CodeUnexpected { + return fmt.Errorf("principal credential admission: %w", err) + } return oops.E(oops.CodeUnexpected, err, "failed to load access grants").LogError(ctx, s.logger) } if err := s.authz.Require(ctx, authz.MCPCheck(authz.ScopeMCPConnect, cfg.rbacResourceID.String(), toolset.ProjectID.String())); err != nil { diff --git a/server/internal/mcp/serveendpoint.go b/server/internal/mcp/serveendpoint.go index 7e3bf8be32e..233922a44b2 100644 --- a/server/internal/mcp/serveendpoint.go +++ b/server/internal/mcp/serveendpoint.go @@ -781,7 +781,8 @@ func (s *Service) prepareProxyBackendContext( // in context. Issuer-gated callers were authenticated by ApplyIssuerGate, // which stamps the principal but does not load grants, so without this they // hit that failure (AGE-2672). PrepareContext runs after identity auth has -// stamped the auth context, and is a no-op for callers RBAC never enforces. +// stamped the auth context. Principal credentials repeat live admission even +// when grants are already loaded or organization RBAC is disabled. // // Public servers bypass server-level RBAC by design; unknown visibility // fails closed. @@ -796,6 +797,10 @@ func (s *Service) authorizeProxyBackendAccess( var prepErr error ctx, prepErr = s.authz.PrepareContext(ctx) if prepErr != nil { + var shareable *oops.ShareableError + if errors.As(prepErr, &shareable) && shareable.Code != oops.CodeUnexpected { + return nil, fmt.Errorf("principal credential admission: %w", prepErr) + } return nil, oops.E(oops.CodeUnexpected, prepErr, "load access grants").LogError(ctx, logger) } From 02634429f0821a17ec3222c3e8d115c5e1ec122e Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 9 Sep 2026 13:54:49 +0100 Subject: [PATCH 11/12] fix: preserve agent actor attribution in request telemetry --- .changeset/live-agent-credential-admission.md | 5 ++ server/internal/auth/actor_telemetry_test.go | 32 ++++++++ server/internal/auth/auth.go | 3 + server/internal/authz/decision_telemetry.go | 21 +----- .../internal/contextvalues/actor_telemetry.go | 31 ++++++++ .../contextvalues/actor_telemetry_test.go | 44 +++++++++++ server/internal/mcp/rpc_resources_read.go | 1 + server/internal/mcp/rpc_tools_call.go | 1 + server/internal/mcp/serve_meta.go | 1 + server/internal/mcp/serve_platform.go | 1 + .../telemetry/actor_telemetry_test.go | 73 +++++++++++++++++++ .../internal/telemetry/attribute_recorder.go | 12 +++ server/internal/telemetry/impl.go | 8 ++ 13 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 .changeset/live-agent-credential-admission.md create mode 100644 server/internal/auth/actor_telemetry_test.go create mode 100644 server/internal/contextvalues/actor_telemetry.go create mode 100644 server/internal/contextvalues/actor_telemetry_test.go create mode 100644 server/internal/telemetry/actor_telemetry_test.go diff --git a/.changeset/live-agent-credential-admission.md b/.changeset/live-agent-credential-admission.md new file mode 100644 index 00000000000..1a53e1afd78 --- /dev/null +++ b/.changeset/live-agent-credential-admission.md @@ -0,0 +1,5 @@ +--- +"server": minor +--- + +Enforce live agent credential authorization against delegated permissions and current agent and owner policies. Reject inactive or expired credentials while preserving authentication errors on private MCP requests. diff --git a/server/internal/auth/actor_telemetry_test.go b/server/internal/auth/actor_telemetry_test.go new file mode 100644 index 00000000000..4db7efaaf7a --- /dev/null +++ b/server/internal/auth/actor_telemetry_test.go @@ -0,0 +1,32 @@ +package auth + +import ( + "log/slog" + "testing" + + "github.com/speakeasy-api/gram/server/internal/constants" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/urn" + "github.com/speakeasy-api/gram/server/internal/wide" + "github.com/stretchr/testify/require" +) + +func TestLogAuthContextTrustedActor(t *testing.T) { + t.Parallel() + base := &contextvalues.AuthContext{ActiveOrganizationID: "org_test", APIKeyID: "key_test"} + actor := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + ctx := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), base, actor, contextvalues.PrincipalCredential{AuthorizerUserID: "user_authorizer"}) + ctx = contextvalues.WithPrincipalCredentialOwner(ctx, "user_owner") + ctx = wide.Start(ctx, slog.String("gram.authorization.actor.id", "forged")) + svc := &Auth{} + svc.logAuthContext(ctx, nil, constants.KeySecurityScheme) + attrs := map[string]string{} + for _, a := range wide.Emit(ctx) { + attrs[a.Key] = a.Value.String() + } + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + require.Equal(t, value, attrs[key]) + } + require.Empty(t, base.UserID) + require.Nil(t, base.Email) +} diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 9a4e291f960..7cecbacb804 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -279,5 +279,8 @@ func (s *Auth) logAuthContext(ctx context.Context, err error, scheme string) { attrs = append(attrs, attr.SlogRequestAuthProjectSlug(*authCtx.ProjectSlug)) } + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + attrs = append(attrs, slog.String(key, value)) + } wide.Push(ctx, attrs...) } diff --git a/server/internal/authz/decision_telemetry.go b/server/internal/authz/decision_telemetry.go index 23d2e44de4b..44f3712e954 100644 --- a/server/internal/authz/decision_telemetry.go +++ b/server/internal/authz/decision_telemetry.go @@ -29,24 +29,9 @@ func RecordAuthorizationDecision(ctx context.Context, operation repo.Operation, attribute.String("gram.authorization.result", boundedOutcome(outcome)), attribute.String("gram.authorization.reason", boundedReason(reason)), } - if authCtx, ok := contextvalues.GetAuthContext(ctx); ok && authCtx != nil { - if authCtx.ActiveOrganizationID != "" { - attrs = append(attrs, attribute.String("gram.authorization.organization_id", authCtx.ActiveOrganizationID)) - } - if actor, ok := contextvalues.AuthenticatedActor(ctx); ok { - attrs = append(attrs, - attribute.String("gram.authorization.actor.type", string(actor.Type)), - attribute.String("gram.authorization.actor.id", actor.ID), - ) - } - if authCtx.APIKeyID != "" { - attrs = append(attrs, attribute.String("gram.authorization.api_key_id", authCtx.APIKeyID)) - } - if authorizerUserID, ownerUserID, ok := contextvalues.PrincipalCredentialProvenance(ctx); ok { - attrs = append(attrs, - attribute.String("gram.authorization.authorizer_user_id", authorizerUserID), - attribute.String("gram.authorization.owner_user_id", ownerUserID), - ) + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + if value != "" { + attrs = append(attrs, attribute.String(key, value)) } } if clientID, ok := contextvalues.GetOAuthClientID(ctx); ok { diff --git a/server/internal/contextvalues/actor_telemetry.go b/server/internal/contextvalues/actor_telemetry.go new file mode 100644 index 00000000000..c60ac31472c --- /dev/null +++ b/server/internal/contextvalues/actor_telemetry.go @@ -0,0 +1,31 @@ +package contextvalues + +import "context" + +// ActorTelemetryAttributes returns the reserved identifier-only authorization +// attributes. Empty values mean the attribute must be removed, not retained from +// caller input. Human identity is never inferred from an owner or authorizer. +// Use only for telemetry describing this request, not bulk ingested events. +func ActorTelemetryAttributes(ctx context.Context) map[string]string { + attrs := map[string]string{ + "gram.authorization.organization_id": "", + "gram.authorization.actor.type": "", + "gram.authorization.actor.id": "", + "gram.authorization.api_key_id": "", + "gram.authorization.authorizer_user_id": "", + "gram.authorization.owner_user_id": "", + } + if authCtx, ok := GetAuthContext(ctx); ok && authCtx != nil { + attrs["gram.authorization.organization_id"] = authCtx.ActiveOrganizationID + attrs["gram.authorization.api_key_id"] = authCtx.APIKeyID + } + if actor, ok := AuthenticatedActor(ctx); ok { + attrs["gram.authorization.actor.type"] = string(actor.Type) + attrs["gram.authorization.actor.id"] = actor.ID + } + if authorizer, owner, ok := PrincipalCredentialProvenance(ctx); ok { + attrs["gram.authorization.authorizer_user_id"] = authorizer + attrs["gram.authorization.owner_user_id"] = owner + } + return attrs +} diff --git a/server/internal/contextvalues/actor_telemetry_test.go b/server/internal/contextvalues/actor_telemetry_test.go new file mode 100644 index 00000000000..01ea9ddc037 --- /dev/null +++ b/server/internal/contextvalues/actor_telemetry_test.go @@ -0,0 +1,44 @@ +package contextvalues + +import ( + "testing" + + "github.com/speakeasy-api/gram/server/internal/urn" + "github.com/stretchr/testify/require" +) + +func TestActorTelemetryAttributes(t *testing.T) { + t.Parallel() + base := &AuthContext{ActiveOrganizationID: "org_test", APIKeyID: "key_test"} + principal := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + ctx := WithPrincipalAPIKeyAuthorization(t.Context(), base, principal, PrincipalCredential{AuthorizerUserID: "user_authorizer"}) + ctx = WithPrincipalCredentialOwner(ctx, "user_owner") + attrs := ActorTelemetryAttributes(ctx) + require.Equal(t, map[string]string{ + "gram.authorization.organization_id": "org_test", + "gram.authorization.actor.type": "agent", + "gram.authorization.actor.id": principal.ID, + "gram.authorization.api_key_id": "key_test", + "gram.authorization.authorizer_user_id": "user_authorizer", + "gram.authorization.owner_user_id": "user_owner", + }, attrs) + authCtx, _ := GetAuthContext(ctx) + require.Empty(t, authCtx.UserID) + require.Nil(t, authCtx.Email) + + legacy := *base + legacy.UserID = "user_legacy" + attrs = ActorTelemetryAttributes(WithLegacyAPIKeyAuthorization(t.Context(), &legacy)) + require.Equal(t, "user", attrs["gram.authorization.actor.type"]) + require.Equal(t, legacy.UserID, attrs["gram.authorization.actor.id"]) + require.Empty(t, attrs["gram.authorization.authorizer_user_id"]) + require.Empty(t, attrs["gram.authorization.owner_user_id"]) + + // Public identity fields alone cannot claim a canonical actor. + attrs = ActorTelemetryAttributes(SetAuthContext(t.Context(), &legacy)) + require.Empty(t, attrs["gram.authorization.actor.type"]) + require.Empty(t, attrs["gram.authorization.actor.id"]) + for _, value := range ActorTelemetryAttributes(t.Context()) { + require.Empty(t, value) + } +} diff --git a/server/internal/mcp/rpc_resources_read.go b/server/internal/mcp/rpc_resources_read.go index 8cb3708a61f..191738d4967 100644 --- a/server/internal/mcp/rpc_resources_read.go +++ b/server/internal/mcp/rpc_resources_read.go @@ -175,6 +175,7 @@ func handleResourcesRead( logAttrs.RecordRequestBody(requestBytes) logAttrs.RecordResponseBody(outputBytes) logAttrs.RecordTraceContext(ctx) + logAttrs.RecordAuthenticatedActor(ctx) if payload.apiKeyID != "" { logAttrs[attr.APIKeyIDKey] = payload.apiKeyID } diff --git a/server/internal/mcp/rpc_tools_call.go b/server/internal/mcp/rpc_tools_call.go index b2abcd905b4..3ee882a4477 100644 --- a/server/internal/mcp/rpc_tools_call.go +++ b/server/internal/mcp/rpc_tools_call.go @@ -396,6 +396,7 @@ func handleToolsCall( logAttrs.RecordRequestBody(requestBytes) logAttrs.RecordResponseBody(outputBytes) logAttrs.RecordTraceContext(ctx) + logAttrs.RecordAuthenticatedActor(ctx) logAttrs.RecordRequestBodyContent(requestBodyBytes) logAttrs.RecordResponseBodyContent(rw.body.Bytes()) diff --git a/server/internal/mcp/serve_meta.go b/server/internal/mcp/serve_meta.go index c24722a5c19..8b4b1ecaab5 100644 --- a/server/internal/mcp/serve_meta.go +++ b/server/internal/mcp/serve_meta.go @@ -494,6 +494,7 @@ func (s *Service) logMetaDiscovery(ctx context.Context, gate *metaGateContext, t } logAttrs.RecordStatusCode(statusCode) logAttrs.RecordTraceContext(ctx) + logAttrs.RecordAuthenticatedActor(ctx) if gate.chatID != "" { logAttrs[attr.GenAIConversationIDKey] = gate.chatID } diff --git a/server/internal/mcp/serve_platform.go b/server/internal/mcp/serve_platform.go index e792ed622be..a0ffe090995 100644 --- a/server/internal/mcp/serve_platform.go +++ b/server/internal/mcp/serve_platform.go @@ -467,6 +467,7 @@ func (s *Service) callPlatformToolsetTool( logAttrs.RecordRequestBody(requestBytes) logAttrs.RecordResponseBody(outputBytes) logAttrs.RecordTraceContext(ctx) + logAttrs.RecordAuthenticatedActor(ctx) logAttrs.RecordRequestBodyContent(requestBodyBytes) logAttrs.RecordResponseBodyContent(rw.body.Bytes()) diff --git a/server/internal/telemetry/actor_telemetry_test.go b/server/internal/telemetry/actor_telemetry_test.go new file mode 100644 index 00000000000..c7c07ce781c --- /dev/null +++ b/server/internal/telemetry/actor_telemetry_test.go @@ -0,0 +1,73 @@ +package telemetry + +import ( + "context" + telem_gen "github.com/speakeasy-api/gram/server/gen/telemetry" + "log/slog" + "testing" + + "github.com/speakeasy-api/gram/server/internal/attr" + "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/urn" + "github.com/stretchr/testify/require" +) + +func TestRecordAuthenticatedActorOverridesForgedAttributes(t *testing.T) { + t.Parallel() + base := &contextvalues.AuthContext{ActiveOrganizationID: "org_test", APIKeyID: "key_test"} + actor := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + ctx := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), base, actor, contextvalues.PrincipalCredential{AuthorizerUserID: "user_authorizer"}) + ctx = contextvalues.WithPrincipalCredentialOwner(ctx, "user_owner") + h := HTTPLogAttributes{attr.Key("test.property"): "test"} + for key := range contextvalues.ActorTelemetryAttributes(ctx) { + h[attr.Key(key)] = "forged" + } + h.RecordAuthenticatedActor(ctx) + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + require.Equal(t, value, h[attr.Key(key)]) + } + require.Equal(t, "test", h[attr.Key("test.property")]) + base.UserID = "user_legacy" + h.RecordAuthenticatedActor(contextvalues.WithLegacyAPIKeyAuthorization(t.Context(), base)) + require.Equal(t, "user_legacy", h[attr.Key("gram.authorization.actor.id")]) + require.NotContains(t, h, attr.Key("gram.authorization.owner_user_id")) + require.NotContains(t, h, attr.Key("gram.authorization.authorizer_user_id")) + h.RecordAuthenticatedActor(t.Context()) + for key := range contextvalues.ActorTelemetryAttributes(t.Context()) { + require.NotContains(t, h, attr.Key(key)) + } +} + +type actorCapture struct { + properties map[string]any + distinctID string +} + +func (c *actorCapture) CaptureEvent(_ context.Context, _ string, distinctID string, properties map[string]any) error { + c.properties = properties + c.distinctID = distinctID + return nil +} + +func TestCaptureEventTrustedActor(t *testing.T) { + t.Parallel() + actor := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") + ctx := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), &contextvalues.AuthContext{ + ActiveOrganizationID: "org_test", APIKeyID: "key_test", + }, actor, contextvalues.PrincipalCredential{AuthorizerUserID: "user_authorizer"}) + ctx = contextvalues.WithPrincipalCredentialOwner(ctx, "user_owner") + capture := &actorCapture{} + svc := &Service{posthog: capture, logger: slog.Default()} + properties := map[string]any{"email": "forged", "user_id": "forged"} + for key := range contextvalues.ActorTelemetryAttributes(ctx) { + properties[key] = "forged" + } + _, err := svc.CaptureEvent(ctx, &telem_gen.CaptureEventPayload{Event: "test", Properties: properties}) + require.NoError(t, err) + require.Equal(t, "org_test", capture.distinctID) + require.Equal(t, "", capture.properties["user_id"]) + require.NotContains(t, capture.properties, "email") + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + require.Equal(t, value, capture.properties[key]) + } +} diff --git a/server/internal/telemetry/attribute_recorder.go b/server/internal/telemetry/attribute_recorder.go index dcff0b60957..f594e8206e3 100644 --- a/server/internal/telemetry/attribute_recorder.go +++ b/server/internal/telemetry/attribute_recorder.go @@ -6,6 +6,7 @@ import ( "maps" "github.com/speakeasy-api/gram/server/internal/attr" + "github.com/speakeasy-api/gram/server/internal/contextvalues" "github.com/speakeasy-api/gram/server/internal/telemetry/repo" "go.opentelemetry.io/otel/trace" ) @@ -145,3 +146,14 @@ func truncateBody(body []byte) string { } return string(body[:maxBodyContentBytes]) + fmt.Sprintf("...[truncated, original size: %d bytes]", len(body)) } + +// RecordAuthenticatedActor overwrites reserved attribution at server-generated +// request sinks. It must not be applied to bulk ingested telemetry. +func (h HTTPLogAttributes) RecordAuthenticatedActor(ctx context.Context) { + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + delete(h, attr.Key(key)) + if value != "" { + h[attr.Key(key)] = value + } + } +} diff --git a/server/internal/telemetry/impl.go b/server/internal/telemetry/impl.go index fb52ad83b3c..d67c608a32a 100644 --- a/server/internal/telemetry/impl.go +++ b/server/internal/telemetry/impl.go @@ -1925,6 +1925,7 @@ func (s *Service) CaptureEvent(ctx context.Context, payload *telem_gen.CaptureEv properties = payload.Properties } + delete(properties, "email") if authCtx.Email != nil { properties["email"] = *authCtx.Email } @@ -1935,6 +1936,13 @@ func (s *Service) CaptureEvent(ctx context.Context, payload *telem_gen.CaptureEv properties["user_id"] = authCtx.UserID properties["external_user_id"] = authCtx.ExternalUserID + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + delete(properties, key) + if value != "" { + properties[key] = value + } + } + // Capture event in PostHog if err := s.posthog.CaptureEvent(ctx, payload.Event, distinctID, properties); err != nil { return nil, oops.E(oops.CodeUnexpected, err, "failed to capture event"). From e87e3686edc0a91322521afadc9105d19f0c1acc Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 9 Sep 2026 14:18:47 +0100 Subject: [PATCH 12/12] fix: omit empty and duplicate request actor attributes --- server/internal/attr/conventions.go | 26 ++++++++++ server/internal/auth/actor_telemetry_test.go | 52 +++++++++++++++---- server/internal/auth/auth.go | 14 ++++- .../mcp/credential_admission_internal_test.go | 6 ++- .../telemetry/actor_telemetry_test.go | 8 +-- 5 files changed, 88 insertions(+), 18 deletions(-) diff --git a/server/internal/attr/conventions.go b/server/internal/attr/conventions.go index e54890228fa..acda5b328cd 100644 --- a/server/internal/attr/conventions.go +++ b/server/internal/attr/conventions.go @@ -12,6 +12,13 @@ import ( type Key = attribute.Key const ( + AuthorizationOrganizationIDKey = attribute.Key("gram.authorization.organization_id") + AuthorizationActorTypeKey = attribute.Key("gram.authorization.actor.type") + AuthorizationActorIDKey = attribute.Key("gram.authorization.actor.id") + AuthorizationAPIKeyIDKey = attribute.Key("gram.authorization.api_key_id") + AuthorizationAuthorizerUserIDKey = attribute.Key("gram.authorization.authorizer_user_id") + AuthorizationOwnerUserIDKey = attribute.Key("gram.authorization.owner_user_id") + WideEventKey = attribute.Key("gram.wide_event") ErrorIDKey = attribute.Key("error.id") @@ -2799,3 +2806,22 @@ func ResilienceSubset(v string) attribute.KeyValue { return ResilienceSubsetKey. func SlogResilienceSubset(v string) slog.Attr { return slog.String(string(ResilienceSubsetKey), v) } + +func SlogAuthorizationOrganizationID(v string) slog.Attr { + return slog.String(string(AuthorizationOrganizationIDKey), v) +} +func SlogAuthorizationActorType(v string) slog.Attr { + return slog.String(string(AuthorizationActorTypeKey), v) +} +func SlogAuthorizationActorID(v string) slog.Attr { + return slog.String(string(AuthorizationActorIDKey), v) +} +func SlogAuthorizationAPIKeyID(v string) slog.Attr { + return slog.String(string(AuthorizationAPIKeyIDKey), v) +} +func SlogAuthorizationAuthorizerUserID(v string) slog.Attr { + return slog.String(string(AuthorizationAuthorizerUserIDKey), v) +} +func SlogAuthorizationOwnerUserID(v string) slog.Attr { + return slog.String(string(AuthorizationOwnerUserIDKey), v) +} diff --git a/server/internal/auth/actor_telemetry_test.go b/server/internal/auth/actor_telemetry_test.go index 4db7efaaf7a..20f27166842 100644 --- a/server/internal/auth/actor_telemetry_test.go +++ b/server/internal/auth/actor_telemetry_test.go @@ -1,7 +1,7 @@ package auth import ( - "log/slog" + "context" "testing" "github.com/speakeasy-api/gram/server/internal/constants" @@ -17,16 +17,46 @@ func TestLogAuthContextTrustedActor(t *testing.T) { actor := urn.NewPrincipal(urn.PrincipalTypeAgent, "018f8d7b-58d7-7cc4-bb16-9f8c6b99a001") ctx := contextvalues.WithPrincipalAPIKeyAuthorization(t.Context(), base, actor, contextvalues.PrincipalCredential{AuthorizerUserID: "user_authorizer"}) ctx = contextvalues.WithPrincipalCredentialOwner(ctx, "user_owner") - ctx = wide.Start(ctx, slog.String("gram.authorization.actor.id", "forged")) - svc := &Auth{} - svc.logAuthContext(ctx, nil, constants.KeySecurityScheme) - attrs := map[string]string{} - for _, a := range wide.Emit(ctx) { - attrs[a.Key] = a.Value.String() - } - for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { - require.Equal(t, value, attrs[key]) - } + ctx = wide.Start(ctx) + assertActorWideAttributes(t, ctx) require.Empty(t, base.UserID) require.Nil(t, base.Email) } + +func TestLogAuthContextLegacyActor(t *testing.T) { + t.Parallel() + for _, userID := range []string{"user_legacy", ""} { + t.Run("user="+userID, func(t *testing.T) { + t.Parallel() + ctx := contextvalues.WithLegacyAPIKeyAuthorization(t.Context(), &contextvalues.AuthContext{ + ActiveOrganizationID: "org_test", APIKeyID: "key_test", UserID: userID, + }) + assertActorWideAttributes(t, wide.Start(ctx)) + }) + } +} + +func assertActorWideAttributes(t *testing.T, ctx context.Context) { + t.Helper() + svc := &Auth{} + for range 2 { + svc.logAuthContext(ctx, nil, constants.KeySecurityScheme) + // Inspect the emitted slice directly: collapsing into a map would hide + // duplicate keys in this append-only collector. + emitted := wide.Emit(ctx) + for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { + count := 0 + for _, a := range emitted { + if a.Key == key { + count++ + require.Equal(t, value, a.Value.String(), key) + } + } + if value == "" { + require.Zero(t, count, key) + } else { + require.Equal(t, 1, count, key) + } + } + } +} diff --git a/server/internal/auth/auth.go b/server/internal/auth/auth.go index 7cecbacb804..e5c67251384 100644 --- a/server/internal/auth/auth.go +++ b/server/internal/auth/auth.go @@ -279,8 +279,18 @@ func (s *Auth) logAuthContext(ctx context.Context, err error, scheme string) { attrs = append(attrs, attr.SlogRequestAuthProjectSlug(*authCtx.ProjectSlug)) } - for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { - attrs = append(attrs, slog.String(key, value)) + actorAttrs := contextvalues.ActorTelemetryAttributes(ctx) + for _, a := range []slog.Attr{ + attr.SlogAuthorizationOrganizationID(actorAttrs[string(attr.AuthorizationOrganizationIDKey)]), + attr.SlogAuthorizationActorType(actorAttrs[string(attr.AuthorizationActorTypeKey)]), + attr.SlogAuthorizationActorID(actorAttrs[string(attr.AuthorizationActorIDKey)]), + attr.SlogAuthorizationAPIKeyID(actorAttrs[string(attr.AuthorizationAPIKeyIDKey)]), + attr.SlogAuthorizationAuthorizerUserID(actorAttrs[string(attr.AuthorizationAuthorizerUserIDKey)]), + attr.SlogAuthorizationOwnerUserID(actorAttrs[string(attr.AuthorizationOwnerUserIDKey)]), + } { + if a.Value.String() != "" && !wide.Contains(ctx, a.Key) { + attrs = append(attrs, a) + } } wide.Push(ctx, attrs...) } diff --git a/server/internal/mcp/credential_admission_internal_test.go b/server/internal/mcp/credential_admission_internal_test.go index aaac829c95a..34df8b1d85a 100644 --- a/server/internal/mcp/credential_admission_internal_test.go +++ b/server/internal/mcp/credential_admission_internal_test.go @@ -112,7 +112,11 @@ func (r *admissionProjectRows) Next() bool { return true } func (r *admissionProjectRows) Scan(dest ...any) error { - *dest[0].(*uuid.UUID) = r.projectID + id, ok := dest[0].(*uuid.UUID) + if !ok { + return fmt.Errorf("expected UUID destination, got %T", dest[0]) + } + *id = r.projectID return nil } func (*admissionProjectRows) Close() {} diff --git a/server/internal/telemetry/actor_telemetry_test.go b/server/internal/telemetry/actor_telemetry_test.go index c7c07ce781c..57d4a96ff2a 100644 --- a/server/internal/telemetry/actor_telemetry_test.go +++ b/server/internal/telemetry/actor_telemetry_test.go @@ -2,12 +2,12 @@ package telemetry import ( "context" - telem_gen "github.com/speakeasy-api/gram/server/gen/telemetry" - "log/slog" "testing" + telem_gen "github.com/speakeasy-api/gram/server/gen/telemetry" "github.com/speakeasy-api/gram/server/internal/attr" "github.com/speakeasy-api/gram/server/internal/contextvalues" + "github.com/speakeasy-api/gram/server/internal/testenv" "github.com/speakeasy-api/gram/server/internal/urn" "github.com/stretchr/testify/require" ) @@ -57,7 +57,7 @@ func TestCaptureEventTrustedActor(t *testing.T) { }, actor, contextvalues.PrincipalCredential{AuthorizerUserID: "user_authorizer"}) ctx = contextvalues.WithPrincipalCredentialOwner(ctx, "user_owner") capture := &actorCapture{} - svc := &Service{posthog: capture, logger: slog.Default()} + svc := &Service{posthog: capture, logger: testenv.NewLogger(t)} properties := map[string]any{"email": "forged", "user_id": "forged"} for key := range contextvalues.ActorTelemetryAttributes(ctx) { properties[key] = "forged" @@ -65,7 +65,7 @@ func TestCaptureEventTrustedActor(t *testing.T) { _, err := svc.CaptureEvent(ctx, &telem_gen.CaptureEventPayload{Event: "test", Properties: properties}) require.NoError(t, err) require.Equal(t, "org_test", capture.distinctID) - require.Equal(t, "", capture.properties["user_id"]) + require.Empty(t, capture.properties["user_id"]) require.NotContains(t, capture.properties, "email") for key, value := range contextvalues.ActorTelemetryAttributes(ctx) { require.Equal(t, value, capture.properties[key])