diff --git a/app/controlplane/internal/usercontext/apitoken_middleware.go b/app/controlplane/internal/usercontext/apitoken_middleware.go index 425c072e6..1a809bb9d 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware.go @@ -71,23 +71,28 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC // We've received an API-token if claimsHaveAudience(genericClaims, apitoken.Audience) { - var err error - tokenID, ok := genericClaims["jti"].(string) - if !ok || tokenID == "" { + claims, err := apitoken.ClaimsFromMap(genericClaims) + if err != nil { + // This control plane never signs a claim of the wrong type. The log line never + // includes the raw token or the claims map. + id, _ := genericClaims["jti"].(string) + logger.Errorw("msg", "[authN] API token claims do not decode", "id", id, "error", err) + return nil, errors.New("error mapping the API-token claims") } - // Project ID is optional - projectID, _ := genericClaims["project_id"].(string) - - workflowID, _ := genericClaims["workflow_id"].(string) + if claims.ID == "" { + return nil, errors.New("error mapping the API-token claims") + } - ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID, workflowID) + ctx, _, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, claims, logger) if err != nil { return nil, fmt.Errorf("error setting current org and user: %w", err) } - logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token", "projectID", projectID) + // legacy_claims marks the tokens minted before the scope claims, to plan the end of + // their support + logger.Infow("msg", "[authN] processed credentials", "id", claims.ID, "type", "API-token", "projectID", claims.ProjectID, "legacy_claims", !claims.HasScopeClaims()) } return handler(ctx, req) @@ -126,81 +131,54 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC * return nil, errors.New("error mapping the API-token claims") } - ctx, err := setRobotAccountFromAPIToken(ctx, apiTokenUC, tokenID) - if err != nil { - return nil, fmt.Errorf("error extracting organization from APIToken: %w", err) - } - - ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID, claims.WorkflowID) + ctx, token, err := setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, claims, logger) if err != nil { return nil, fmt.Errorf("error setting current org and user: %w", err) } - logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token") + // The robot account comes from the row that VerifyClaims checked. + ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey}) + + logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token", "legacy_claims", !claims.HasScopeClaims()) return handler(ctx, req) } } } -func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, tokenID string) (context.Context, error) { - if tokenID == "" { - return nil, errors.New("error retrieving the key ID from the API token") - } - - // Check that the token exists and is not revoked - token, err := apiTokenUC.FindByID(ctx, tokenID) - if err != nil { - return nil, fmt.Errorf("error retrieving the API token: %w", err) - } else if token == nil { - return nil, errors.New("API token not found") - } - - // Note: Expiration time does not need to be checked because that's done at the JWT - // verification layer, which happens before this middleware is called - if token.RevokedAt != nil { - return nil, errors.New("API token revoked") - } - - ctx = WithRobotAccount(ctx, &RobotAccount{OrgID: token.OrganizationID.String(), ProviderKey: attjwtmiddleware.APITokenProviderKey}) - - return ctx, nil -} - -// Set the current organization and API-Token in the context. The project and workflow claims are -// cross-checked against the token row, never an authorization input: the row decides what the -// token reaches. -func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim, workflowIDInClaim string) (context.Context, error) { - if tokenID == "" { - return nil, errors.New("error retrieving the key ID from the API token") +// setCurrentOrgAndAPIToken loads the token's row and checks it against the signed claims. Then it +// puts the organization and the token in the context, and returns the row. The claims fix the +// token's scope, organization, project and workflow. The row must match them. The policies, the +// product project list and the revocation come only from the row. +func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, claims *apitoken.CustomClaims, logger *log.Helper) (context.Context, *biz.APIToken, error) { + if claims == nil || claims.ID == "" { + return nil, nil, errors.New("error retrieving the key ID from the API token") } // Check that the token exists and is not revoked - token, err := apiTokenUC.FindByID(ctx, tokenID) + token, err := apiTokenUC.FindByID(ctx, claims.ID) if err != nil { - return nil, fmt.Errorf("error retrieving the API token: %w", err) + return nil, nil, fmt.Errorf("error retrieving the API token: %w", err) } else if token == nil { - return nil, errors.New("API token not found") + return nil, nil, errors.New("API token not found") } - // Make sure that the projectID that comes in the token claim matches the one in the DB - if projectIDInClaim != "" { - if token.ProjectID == nil || token.ProjectID.String() != projectIDInClaim { - return nil, errors.New("API token project mismatch") + if err := token.VerifyClaims(claims); err != nil { + // A row should never disagree with its signed claims. If it does, something wrote the row + // incorrectly. The log line gives the reason and never includes the raw JWT. The caller + // learns only that the token could not be verified. + if errors.Is(err, biz.ErrAPITokenClaimsMismatch) { + logger.Errorw("msg", "[authN] API token row disagrees with its signed claims", "id", claims.ID, "error", err) + return nil, nil, biz.ErrAPITokenClaimsMismatch } - } - // Same defense in depth for the workflow claim - if workflowIDInClaim != "" { - if token.WorkflowID == nil || token.WorkflowID.String() != workflowIDInClaim { - return nil, errors.New("API token workflow mismatch") - } + return nil, nil, err } // Note: Expiration time does not need to be checked because that's done at the JWT // verification layer, which happens before this middleware is called if token.RevokedAt != nil { - return nil, errors.New("API token revoked") + return nil, nil, errors.New("API token revoked") } // Handle instance admin tokens @@ -212,9 +190,9 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa // Load organization from header org, err := orgUC.FindByName(ctx, orgName) if err != nil { - return nil, fmt.Errorf("error retrieving the organization: %w", err) + return nil, nil, fmt.Errorf("error retrieving the organization: %w", err) } else if org == nil { - return nil, errors.New("organization not found") + return nil, nil, errors.New("organization not found") } ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) @@ -225,15 +203,18 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa } else { org, err := orgUC.FindByID(ctx, token.OrganizationID.String()) if err != nil { - return nil, fmt.Errorf("error retrieving the organization: %w", err) + return nil, nil, fmt.Errorf("error retrieving the organization: %w", err) } else if org == nil { - return nil, errors.New("organization not found") + return nil, nil, errors.New("organization not found") } // Set the current organization in the context ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt, Suspended: org.Suspended}) } + // Every value here comes from the row. VerifyClaims checked the scope, project and workflow + // against the signed claims. The policies, the project list and the system flag come only from + // the row. ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ ID: token.ID.String(), Name: token.Name, @@ -243,19 +224,18 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa ProjectName: token.ProjectName, WorkflowID: token.WorkflowID, WorkflowName: token.WorkflowName, - // Every value here comes from token.*, i.e. the database row - Scope: token.Scope, - ScopeID: token.ScopeID, - ProjectIDs: token.ProjectIDs, - Policies: token.Policies, - IsSystem: token.IsSystem, + Scope: token.Scope, + ScopeID: token.ScopeID, + ProjectIDs: token.ProjectIDs, + Policies: token.Policies, + IsSystem: token.IsSystem, }) // Set the authorization subject that will be used to check the policies subjectAPIToken := authz.SubjectAPIToken{ID: token.ID.String()} ctx = WithAuthzSubject(ctx, subjectAPIToken.String()) - return ctx, nil + return ctx, token, nil } func WithAPITokenUsageUpdater(apiTokenUC *biz.APITokenUseCase, logger *log.Helper) middleware.Middleware { diff --git a/app/controlplane/internal/usercontext/apitoken_middleware_integration_test.go b/app/controlplane/internal/usercontext/apitoken_middleware_integration_test.go index 26587c33e..90debb828 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware_integration_test.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware_integration_test.go @@ -17,13 +17,17 @@ package usercontext import ( "context" + "errors" "io" + "os" "testing" "time" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/apitoken" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/usercontext/entities" "github.com/go-kratos/kratos/v2/log" jwtmiddleware "github.com/go-kratos/kratos/v2/middleware/auth/jwt" @@ -33,9 +37,8 @@ import ( "github.com/stretchr/testify/require" ) -// The token the service layer sees carries exactly the scope its row records. A row recording -// none, such as one a control plane from before the scope columns wrote after the backfill ran, -// is confined to nothing, and with no organization either it is refused. +// After the row matches the signed claims, the service layer gets a token with exactly the scope +// that the row records. func TestAPITokenMiddlewareCarriesTheRowScope(t *testing.T) { if !testhelpers.IntegrationTestsEnabled() { t.Skip() @@ -80,16 +83,6 @@ func TestAPITokenMiddlewareCarriesTheRowScope(t *testing.T) { opts: biz.APITokenCreateOpts{Scope: biz.ToPtr(authz.ResourceTypeInstance)}, wantScope: biz.ToPtr(authz.ResourceTypeInstance), wantInstanceScoped: true, }, - { - name: "an organization row recording no scope is confined to nothing", - opts: biz.APITokenCreateOpts{OrganizationID: &orgID}, - wantReach: []uuid.UUID{}, - }, - { - name: "a row with neither an organization nor a scope is refused", - opts: biz.APITokenCreateOpts{}, - wantErr: true, - }, } for _, tc := range testCases { @@ -131,3 +124,316 @@ func TestAPITokenMiddlewareCarriesTheRowScope(t *testing.T) { }) } } + +const scopeBackfillMigration = "../../pkg/data/ent/migrate/migrations/20260930160057.sql" + +// authenticated is what an entry point put in the context, or why it refused the token. +type authenticated struct { + token *entities.APIToken + org *entities.Org + err error +} + +// authenticateAtBothEntryPoints sends signed through the API and the attestation entry points, +// naming orgName in the organization header. +func authenticateAtBothEntryPoints(t *testing.T, tu *testhelpers.TestingUseCases, signed, orgName string) map[string]authenticated { + t.Helper() + + results := make(map[string]authenticated, len(apiTokenEntryPoints)) + for entry, run := range apiTokenEntryPoints { + var got authenticated + got.err = run(tu.APIToken, tu.Organization, signed, orgName, func(ctx context.Context, _ interface{}) (interface{}, error) { + got.token, got.org = entities.CurrentAPIToken(ctx), entities.CurrentOrg(ctx) + return nil, nil + }) + results[entry] = got + } + + return results +} + +// signLegacy signs claims the way a control plane from before the scope claims did, with the +// testhelpers' signing key. +func signLegacy(t *testing.T, tokenID uuid.UUID, claims jwt.MapClaims) string { + t.Helper() + all := jwt.MapClaims{"token_name": "legacy", claimJTI: tokenID.String(), "iss": testIssuer, claimAud: []string{apitoken.Audience}} + for k, v := range claims { + all[k] = v + } + + signed, err := jwt.NewWithClaims(apitoken.SigningMethod, all).SignedString([]byte(testSigningKey)) + require.NoError(t, err) + + return signed +} + +// Organization, project, workflow-pinned and instance tokens minted before the scope claims keep +// working, with the scope they always had. The scope backfill gave their rows that scope, and their +// claims imply the same scope. Both entry points refuse two cases, because the row does not record +// the scope that the claims imply: +// - a product row whose JWT names no scope. Such claims imply the organization. +// - a row that a control plane wrote with no scope after the backfill ran. +func TestAPITokenMiddlewareAcceptsTokensMintedBeforeTheScopeClaims(t *testing.T) { + if !testhelpers.IntegrationTestsEnabled() { + t.Skip() + } + + tu := testhelpers.NewTestingUseCases(t) + defer tu.DB.Close(t) + ctx := context.Background() + + org, err := tu.Organization.CreateWithRandomName(ctx) + require.NoError(t, err) + orgID := uuid.MustParse(org.ID) + headerOrg, err := tu.Organization.CreateWithRandomName(ctx) + require.NoError(t, err) + headerOrgID := uuid.MustParse(headerOrg.ID) + project, err := tu.Project.Create(ctx, org.ID, "legacy") + require.NoError(t, err) + other, err := tu.Project.Create(ctx, org.ID, "other") + require.NoError(t, err) + workflow, err := tu.Workflow.Create(ctx, &biz.WorkflowCreateOpts{OrgID: org.ID, Name: "legacy", Project: project.Name}) + require.NoError(t, err) + product := uuid.New() + + orgClaims := jwt.MapClaims{claimOrgID: org.ID, claimOrgName: org.Name} + projectClaims := jwt.MapClaims{claimOrgID: org.ID, claimOrgName: org.Name, claimProjectID: project.ID.String(), "project_name": project.Name} + instanceClaims := jwt.MapClaims{claimOrgID: "", claimOrgName: "", "scope": authz.ScopeInstanceAdmin} + + testCases := []struct { + name string + // row sets the columns that the minting control plane wrote. A nil row sets no columns, as + // for an instance token. + row func(*ent.APITokenCreate) *ent.APITokenCreate + claims jwt.MapClaims + header string + // afterBackfill writes the row after the backfill runs. A control plane from before the + // scope columns does this during a rolling upgrade or after a rollback. + afterBackfill bool + + wantErr string + // mismatch marks a refusal because the row disagrees with the claims + mismatch bool + wantScope authz.ResourceType + wantScopeID *uuid.UUID + wantOrg *uuid.UUID + // wantReach lists the projects that the token reaches. nil means every project of its + // organization. + wantReach []uuid.UUID + }{ + { + name: "oldest organization token, no org_name claim", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { return c.SetOrganizationID(orgID) }, + claims: jwt.MapClaims{claimOrgID: org.ID}, + wantScope: authz.ResourceTypeOrganization, wantScopeID: &orgID, wantOrg: &orgID, + }, + { + name: "organization token ignores the organization header", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { return c.SetOrganizationID(orgID) }, + claims: orgClaims, header: headerOrg.Name, + wantScope: authz.ResourceTypeOrganization, wantScopeID: &orgID, wantOrg: &orgID, + }, + { + name: "project token", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgID).SetProjectID(project.ID) + }, + claims: projectClaims, + wantScope: authz.ResourceTypeProject, wantScopeID: &project.ID, wantOrg: &orgID, wantReach: []uuid.UUID{project.ID}, + }, + { + name: "workflow-pinned token", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgID).SetProjectID(project.ID).SetWorkflowID(workflow.ID) + }, + claims: jwt.MapClaims{claimOrgID: org.ID, claimOrgName: org.Name, claimProjectID: project.ID.String(), "project_name": project.Name, + "workflow_id": workflow.ID.String(), "workflow_name": workflow.Name}, + wantScope: authz.ResourceTypeProject, wantScopeID: &project.ID, wantOrg: &orgID, wantReach: []uuid.UUID{project.ID}, + }, + { + name: "instance token takes the organization header", + claims: instanceClaims, header: headerOrg.Name, + wantScope: authz.ResourceTypeInstance, wantOrg: &headerOrgID, + }, + { + name: "instance token without the header has no organization", + claims: instanceClaims, + wantScope: authz.ResourceTypeInstance, + }, + { + name: "product row whose JWT names no scope", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgID).SetScope(authz.ResourceTypeProduct).SetScopeID(product).SetProjectIds([]uuid.UUID{project.ID}) + }, + claims: orgClaims, + wantErr: errNotVerifiedAtEntry, + mismatch: true, + }, + { + name: "revoked organization token", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgID).SetRevokedAt(time.Now()) + }, + claims: orgClaims, + wantErr: "revoked", + }, + { + name: "organization row written with no scope after the backfill", + row: func(c *ent.APITokenCreate) *ent.APITokenCreate { return c.SetOrganizationID(orgID) }, + claims: orgClaims, + afterBackfill: true, + wantErr: errNotVerifiedAtEntry, + mismatch: true, + }, + { + name: "instance row written with no scope after the backfill", + claims: instanceClaims, + afterBackfill: true, + wantErr: errNotVerifiedAtEntry, + mismatch: true, + }, + } + + create := func(row func(*ent.APITokenCreate) *ent.APITokenCreate) uuid.UUID { + c := tu.Data.DB.APIToken.Create().SetName("legacy-" + uuid.NewString()) + if row != nil { + c = row(c) + } + + saved, err := c.Save(ctx) + require.NoError(t, err) + return saved.ID + } + + ids := make([]uuid.UUID, len(testCases)) + for i, tc := range testCases { + if !tc.afterBackfill { + ids[i] = create(tc.row) + } + } + + backfill, err := os.ReadFile(scopeBackfillMigration) + require.NoError(t, err) + _, err = tu.Data.SQLDB.ExecContext(ctx, string(backfill)) + require.NoError(t, err) + + for i, tc := range testCases { + if tc.afterBackfill { + ids[i] = create(tc.row) + } + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for entry, got := range authenticateAtBothEntryPoints(t, tu, signLegacy(t, ids[i], tc.claims), tc.header) { + if tc.wantErr != "" { + assert.ErrorContains(t, got.err, tc.wantErr, entry) + assert.Equal(t, tc.mismatch, errors.Is(got.err, biz.ErrAPITokenClaimsMismatch), entry) + continue + } + + require.NoError(t, got.err, entry) + require.NotNil(t, got.token, entry) + require.NotNil(t, got.token.Scope, entry) + assert.Equal(t, tc.wantScope, *got.token.Scope, entry) + assert.Equal(t, tc.wantScopeID, got.token.ScopeID, entry) + if tc.wantOrg == nil { + assert.Nil(t, got.org, entry) + } else { + require.NotNil(t, got.org, entry) + assert.Equal(t, tc.wantOrg.String(), got.org.ID, entry) + } + if tc.wantReach == nil { + assert.Nil(t, got.token.ReachableProjects(), entry) + assert.True(t, got.token.ReachesProject(other.ID), entry) + } else { + assert.Equal(t, tc.wantReach, got.token.ReachableProjects(), entry) + assert.False(t, got.token.ReachesProject(other.ID), entry) + } + } + }) + } +} + +// Both entry points refuse a token whose row disagrees with its signed claims, and treat it as a +// security event. A wrong row cannot widen the token, move it to another resource or organization, +// or make it an instance token. +func TestAPITokenMiddlewareRefusesARowThatDisagreesWithItsClaims(t *testing.T) { + if !testhelpers.IntegrationTestsEnabled() { + t.Skip() + } + + tu := testhelpers.NewTestingUseCases(t) + defer tu.DB.Close(t) + ctx := context.Background() + + org, err := tu.Organization.CreateWithRandomName(ctx) + require.NoError(t, err) + otherOrg, err := tu.Organization.CreateWithRandomName(ctx) + require.NoError(t, err) + orgID, otherOrgID := uuid.MustParse(org.ID), uuid.MustParse(otherOrg.ID) + project, err := tu.Project.Create(ctx, org.ID, "signed") + require.NoError(t, err) + other, err := tu.Project.Create(ctx, org.ID, "other") + require.NoError(t, err) + product, otherProduct := uuid.New(), uuid.New() + + orgToken := []biz.APITokenCreateOpt{} + projectToken := []biz.APITokenCreateOpt{biz.APITokenWithProject(project)} + productToken := []biz.APITokenCreateOpt{biz.APITokenWithScope(authz.ResourceTypeProduct, &product), biz.APITokenWithProjectIDs([]uuid.UUID{project.ID})} + + testCases := []struct { + name string + opts []biz.APITokenCreateOpt + // alter changes the row after the token is minted. nil leaves the row as it was minted. + alter func(*ent.APITokenUpdateOne) *ent.APITokenUpdateOne + header string + wantErr string + }{ + {name: "an organization token as minted", opts: orgToken}, + {name: "a project token as minted", opts: projectToken}, + {name: "a product token as minted", opts: productToken}, + {name: "a project token widened to its organization", opts: projectToken, wantErr: errNotVerifiedAtEntry, + alter: func(u *ent.APITokenUpdateOne) *ent.APITokenUpdateOne { + return u.SetScope(authz.ResourceTypeOrganization).SetScopeID(orgID) + }}, + {name: "an organization token made an instance token", opts: orgToken, header: otherOrg.Name, wantErr: errNotVerifiedAtEntry, + alter: func(u *ent.APITokenUpdateOne) *ent.APITokenUpdateOne { + return u.SetScope(authz.ResourceTypeInstance).ClearScopeID() + }}, + {name: "a project token moved to another project", opts: projectToken, wantErr: errNotVerifiedAtEntry, + alter: func(u *ent.APITokenUpdateOne) *ent.APITokenUpdateOne { return u.SetScopeID(other.ID) }}, + {name: "an organization token moved to another organization", opts: orgToken, wantErr: errNotVerifiedAtEntry, + alter: func(u *ent.APITokenUpdateOne) *ent.APITokenUpdateOne { + return u.SetOrganizationID(otherOrgID).SetScopeID(otherOrgID) + }}, + {name: "a product token moved to another product", opts: productToken, wantErr: errNotVerifiedAtEntry, + alter: func(u *ent.APITokenUpdateOne) *ent.APITokenUpdateOne { return u.SetScopeID(otherProduct) }}, + {name: "a product token moved to another organization", opts: productToken, wantErr: errNotVerifiedAtEntry, + alter: func(u *ent.APITokenUpdateOne) *ent.APITokenUpdateOne { return u.SetOrganizationID(otherOrgID) }}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + token, err := tu.APIToken.Create(ctx, "signed-"+uuid.NewString()[:8], nil, nil, &org.ID, tc.opts...) + require.NoError(t, err) + if tc.alter != nil { + require.NoError(t, tc.alter(tu.Data.DB.APIToken.UpdateOneID(token.ID)).Exec(ctx)) + } + + for entry, got := range authenticateAtBothEntryPoints(t, tu, token.JWT, tc.header) { + if tc.wantErr != "" { + assert.EqualError(t, got.err, tc.wantErr, entry) + assert.ErrorIs(t, got.err, biz.ErrAPITokenClaimsMismatch, entry) + assert.Nil(t, got.token, entry) + continue + } + + require.NoError(t, got.err, entry) + require.NotNil(t, got.token, entry) + assert.Equal(t, token.Scope, got.token.Scope, entry) + assert.Equal(t, token.ScopeID, got.token.ScopeID, entry) + } + }) + } +} diff --git a/app/controlplane/internal/usercontext/apitoken_middleware_test.go b/app/controlplane/internal/usercontext/apitoken_middleware_test.go index 946dfff9d..7c966be58 100644 --- a/app/controlplane/internal/usercontext/apitoken_middleware_test.go +++ b/app/controlplane/internal/usercontext/apitoken_middleware_test.go @@ -16,6 +16,7 @@ package usercontext import ( + "bytes" "context" "fmt" "io" @@ -39,8 +40,63 @@ import ( "github.com/stretchr/testify/require" ) -// authorizationHeader carries the bearer token the attestation entry point reads. -const authorizationHeader = "Authorization" +// Claim names, error substrings and entry point names that the tests of both entry points share. +const ( + claimAud = "aud" + claimJTI = "jti" + claimOrgID = "org_id" + claimOrgName = "org_name" + claimProjectID = "project_id" + claimScopeID = "scope_id" + claimScope = "scope" + // errNotVerified is all that a caller learns about a token whose row disagrees with its claims + errNotVerified = "API token could not be verified" + // errNotVerifiedAtEntry is the whole error that both entry points return for such a token. The + // tests compare the whole text, so that no reason can leak into it. + errNotVerifiedAtEntry = "error setting current org and user: " + errNotVerified + entryAPI = "API" + entryAttestation = "attestation" +) + +const ( + // authorizationHeader carries the bearer token the attestation entry point reads. + authorizationHeader = "Authorization" + // orgHeader names the organization an instance token acts in. + orgHeader = "Chainloop-Organization" + // testSigningKey is the key that signs the test tokens. The testhelpers use the same key. + testSigningKey = "test" + // testIssuer is the issuer in the test tokens. Nothing checks it. + testIssuer = "cp.chainloop" +) + +// apiTokenEntryPoints holds one function per entry point. Each function runs a signed API token +// through its entry point, with orgName in the organization header. handler runs only when the +// entry point accepts the token. +var apiTokenEntryPoints = map[string]func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed, orgName string, handler middleware.Handler) error{ + entryAPI: func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed, orgName string, handler middleware.Handler) error { + claims := jwt.MapClaims{} + if _, _, err := jwt.NewParser().ParseUnverified(signed, claims); err != nil { + return err + } + + ctx := transport.NewServerContext(context.Background(), &fakeTransport{header: headerCarrier{orgHeader: {orgName}}}) + logger := log.NewHelper(log.NewStdLogger(io.Discard)) + _, err := WithCurrentAPITokenAndOrgMiddleware(apiTokenUC, orgUC, logger)(handler)(jwtmiddleware.NewContext(ctx, claims), nil) + return err + }, + entryAttestation: func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed, orgName string, handler middleware.Handler) error { + ctx := transport.NewServerContext(context.Background(), &fakeTransport{header: headerCarrier{ + authorizationHeader: {"Bearer " + signed}, + orgHeader: {orgName}, + }}) + logger := log.NewHelper(log.NewStdLogger(io.Discard)) + _, err := middleware.Chain( + attjwtmiddleware.WithJWTMulti(log.NewStdLogger(io.Discard), attjwtmiddleware.NewAPITokenProvider(testSigningKey)), + WithAttestationContextFromAPIToken(apiTokenUC, orgUC, logger), + )(handler)(ctx, nil) + return err + }, +} type middlewareTestCase struct { name string @@ -53,6 +109,8 @@ type middlewareTestCase struct { workflowIDClaim string // tokenWorkflowID, if set, is the workflow_id stored on the DB row tokenWorkflowID *uuid.UUID + // extraClaims adds claims to the JWT. + extraClaims jwt.MapClaims // the middleware logic got skipped skipped bool wantErr bool @@ -63,6 +121,7 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { logger := log.NewHelper(log.NewStdLogger(io.Discard)) matchingWorkflowID := uuid.New() otherWorkflowID := uuid.New() + projectID := uuid.New() testCases := []middlewareTestCase{ { name: "invalid audience", // in this case it gets ignored @@ -79,12 +138,13 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { wantErr: false, }, { - name: "token revoked", - receivedToken: true, - audience: apitoken.Audience, - tokenExists: true, - tokenRevoked: true, - wantErr: true, + name: "token revoked", + receivedToken: true, + audience: apitoken.Audience, + tokenExists: true, + tokenRevoked: true, + wantErr: true, + wantErrContains: "revoked", }, { name: "token does not exist", @@ -94,11 +154,12 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { wantErr: true, }, { - name: "org does not exist", - receivedToken: true, - audience: apitoken.Audience, - tokenExists: true, - wantErr: true, + name: "org does not exist", + receivedToken: true, + audience: apitoken.Audience, + tokenExists: true, + wantErr: true, + wantErrContains: "organization not found", }, { name: "no token received", @@ -123,7 +184,7 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { workflowIDClaim: matchingWorkflowID.String(), tokenWorkflowID: &otherWorkflowID, wantErr: true, - wantErrContains: "workflow mismatch", + wantErrContains: errNotVerified, }, { name: "workflow claim present but DB row has none", @@ -132,19 +193,36 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { tokenExists: true, workflowIDClaim: matchingWorkflowID.String(), wantErr: true, - wantErrContains: "workflow mismatch", + wantErrContains: errNotVerified, + }, + { + name: "scope claims disagree with the DB row", + receivedToken: true, + audience: apitoken.Audience, + tokenExists: true, + extraClaims: jwt.MapClaims{claimScope: "product", claimScopeID: uuid.NewString()}, + wantErr: true, + wantErrContains: errNotVerified, + }, + { + name: "a claim of the wrong type is refused", + receivedToken: true, + audience: apitoken.Audience, + extraClaims: jwt.MapClaims{claimProjectID: 42}, + wantErr: true, + wantErrContains: "mapping the API-token claims", }, } for _, tc := range testCases { wantOrgID := uuid.New() wantOrg := &biz.Organization{ID: wantOrgID.String()} - wantToken := &biz.APIToken{ID: uuid.New(), OrganizationID: wantOrgID} + wantToken := &biz.APIToken{ID: uuid.New(), OrganizationID: wantOrgID, Scope: biz.ToPtr(authz.ResourceTypeOrganization), ScopeID: &wantOrgID} t.Run(tc.name, func(t *testing.T) { apiTokenRepo := mocks.NewAPITokenRepo(t) orgRepo := mocks.NewOrganizationRepo(t) - apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: "test"}, nil, nil, nil, nil) + apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: testSigningKey}, nil, nil, nil, nil) require.NoError(t, err) orgUC := biz.NewOrganizationUseCase(orgRepo, nil, nil, nil, nil, nil, nil) require.NoError(t, err) @@ -152,11 +230,20 @@ func TestWithCurrentAPITokenAndOrgMiddleware(t *testing.T) { ctx := context.Background() if tc.receivedToken { c := jwt.MapClaims{ - "aud": tc.audience, - "jti": wantToken.ID.String(), + claimAud: tc.audience, + claimJTI: wantToken.ID.String(), + claimOrgID: wantOrgID.String(), } if tc.workflowIDClaim != "" { + // A workflow claim always comes with its project c["workflow_id"] = tc.workflowIDClaim + c[claimProjectID] = projectID.String() + wantToken.ProjectID = &projectID + wantToken.Scope = biz.ToPtr(authz.ResourceTypeProject) + wantToken.ScopeID = &projectID + } + for k, v := range tc.extraClaims { + c[k] = v } ctx = jwtmiddleware.NewContext(ctx, c) @@ -214,43 +301,49 @@ func toTimePtr(t time.Time) *time.Time { return &t } -// The resource scope must reach the service layer from the database row, never from a claim: -// the row is the authorization input, the claim is only ever a cross-check. +// After the row matches the signed claims, the service layer gets the scope from the database row. func TestWithCurrentAPITokenAndOrgMiddlewareCarriesScope(t *testing.T) { logger := log.NewHelper(log.NewStdLogger(io.Discard)) productID := uuid.New() projectA := uuid.New() + orgID := uuid.New() testCases := []struct { name string // scope as stored on the token row + rowOrg uuid.UUID rowScope *authz.ResourceType rowScopeID *uuid.UUID rowProjectIDs []uuid.UUID - // instanceAdminClaim signs the JWT with the instance-admin "scope" claim - instanceAdminClaim bool + // claims are the signed claims other than aud and jti. + claims jwt.MapClaims }{ { name: "a product-scoped token", + rowOrg: orgID, rowScope: biz.ToPtr(authz.ResourceTypeProduct), rowScopeID: &productID, rowProjectIDs: []uuid.UUID{projectA}, + claims: jwt.MapClaims{claimOrgID: orgID.String(), claimScope: "product", claimScopeID: productID.String()}, }, { - name: "an unscoped token carries no scope", + name: "an organization token with legacy claims", + rowOrg: orgID, + rowScope: biz.ToPtr(authz.ResourceTypeOrganization), + rowScopeID: &orgID, + claims: jwt.MapClaims{claimOrgID: orgID.String()}, }, { - // The instance-admin claim never becomes the token's resource scope. - name: "an instance-admin claim carries no scope", - instanceAdminClaim: true, + name: "an instance token", + rowScope: biz.ToPtr(authz.ResourceTypeInstance), + claims: jwt.MapClaims{claimOrgID: "", claimScope: string(authz.ResourceTypeInstance)}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - orgID := uuid.New() token := &biz.APIToken{ - ID: uuid.New(), Name: "ci", OrganizationID: orgID, + ID: uuid.New(), Name: "ci", OrganizationID: tc.rowOrg, Scope: tc.rowScope, ScopeID: tc.rowScopeID, ProjectIDs: tc.rowProjectIDs, } @@ -259,13 +352,13 @@ func TestWithCurrentAPITokenAndOrgMiddlewareCarriesScope(t *testing.T) { orgRepo := mocks.NewOrganizationRepo(t) orgRepo.On("FindByID", mock.Anything, orgID).Maybe().Return(&biz.Organization{ID: orgID.String()}, nil) - apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: "test"}, nil, nil, nil, nil) + apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: testSigningKey}, nil, nil, nil, nil) require.NoError(t, err) orgUC := biz.NewOrganizationUseCase(orgRepo, nil, nil, nil, nil, nil, nil) - claims := jwt.MapClaims{"aud": apitoken.Audience, "jti": token.ID.String()} - if tc.instanceAdminClaim { - claims["scope"] = authz.ScopeInstanceAdmin + claims := jwt.MapClaims{claimAud: apitoken.Audience, claimJTI: token.ID.String()} + for k, v := range tc.claims { + claims[k] = v } ctx := jwtmiddleware.NewContext(context.Background(), claims) @@ -285,6 +378,41 @@ func TestWithCurrentAPITokenAndOrgMiddlewareCarriesScope(t *testing.T) { } } +// The middleware logs a row that disagrees with its signed claims as a security event. The log line +// includes the token ID and never the raw token. +func TestWithCurrentAPITokenAndOrgMiddlewareLogsAClaimsMismatch(t *testing.T) { + const signedToken = "raw.signed.token" + orgID := uuid.New() + token := &biz.APIToken{ID: uuid.New(), OrganizationID: orgID, Scope: biz.ToPtr(authz.ResourceTypeOrganization), ScopeID: &orgID} + + apiTokenRepo := mocks.NewAPITokenRepo(t) + apiTokenRepo.On("FindByID", mock.Anything, token.ID).Return(token, nil) + orgRepo := mocks.NewOrganizationRepo(t) + apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: testSigningKey}, nil, nil, nil, nil) + require.NoError(t, err) + orgUC := biz.NewOrganizationUseCase(orgRepo, nil, nil, nil, nil, nil, nil) + + var buf bytes.Buffer + logger := log.NewHelper(log.NewStdLogger(&buf)) + + claims := jwt.MapClaims{ + claimAud: apitoken.Audience, claimJTI: token.ID.String(), claimOrgID: orgID.String(), + claimScope: string(authz.ResourceTypeProduct), claimScopeID: uuid.NewString(), + "raw": signedToken, + } + + _, err = WithCurrentAPITokenAndOrgMiddleware(apiTokenUC, orgUC, logger)( + func(context.Context, interface{}) (interface{}, error) { return nil, nil })(jwtmiddleware.NewContext(context.Background(), claims), nil) + + // The caller learns only that the token could not be verified. The log line has the reason. + require.ErrorIs(t, err, biz.ErrAPITokenClaimsMismatch) + require.EqualError(t, err, errNotVerifiedAtEntry) + assert.Contains(t, buf.String(), "disagrees with its signed claims") + assert.Contains(t, buf.String(), "scope mismatch") + assert.Contains(t, buf.String(), token.ID.String()) + assert.NotContains(t, buf.String(), signedToken) +} + // preProductClaimRemoval are the claims a product token was signed with while the control plane // still minted a product_id claim. type preProductClaimRemoval struct { @@ -292,12 +420,10 @@ type preProductClaimRemoval struct { ProductID string `json:"product_id,omitempty"` } -// A JWT minted before the product_id claim was dropped may still carry one. The row decides what -// a token is confined to, so both entry points accept such a JWT and ignore the claim, whatever -// product it names. +// A JWT minted before the product_id claim was removed may still carry one. Both entry points +// ignore that claim, whatever product it names. The signed scope claims decide what the token +// reaches, and the row must match them. func TestAPITokenMiddlewaresIgnoreAProductClaim(t *testing.T) { - const signingKey = "test" - logger := log.NewHelper(log.NewStdLogger(io.Discard)) rowProduct, otherProduct, orgID := uuid.New(), uuid.New(), uuid.New() product, organization := authz.ResourceTypeProduct, authz.ResourceTypeOrganization @@ -306,64 +432,56 @@ func TestAPITokenMiddlewaresIgnoreAProductClaim(t *testing.T) { // rowScope and rowScopeID are the scope stored on the token row rowScope *authz.ResourceType rowScopeID *uuid.UUID + // signScope signs the row's scope into the scope claims + signScope bool // productClaim is the product_id claim the JWT carries productClaim uuid.UUID + wantErr string }{ - {name: "the claim names the row's product", rowScope: &product, rowScopeID: &rowProduct, productClaim: rowProduct}, - {name: "the claim names another product", rowScope: &product, rowScopeID: &rowProduct, productClaim: otherProduct}, + {name: "the claim names the row's product", rowScope: &product, rowScopeID: &rowProduct, signScope: true, productClaim: rowProduct}, + {name: "the claim names another product", rowScope: &product, rowScopeID: &rowProduct, signScope: true, productClaim: otherProduct}, {name: "the claim is on an organization-scoped row", rowScope: &organization, rowScopeID: &orgID, productClaim: orgID}, - {name: "the claim is on a row that records no scope", productClaim: otherProduct}, - } - - type entryPoint func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed string, handler middleware.Handler) error - entryPoints := map[string]entryPoint{ - "API": func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed string, handler middleware.Handler) error { - claims := jwt.MapClaims{} - if _, _, err := jwt.NewParser().ParseUnverified(signed, claims); err != nil { - return err - } - - _, err := WithCurrentAPITokenAndOrgMiddleware(apiTokenUC, orgUC, logger)(handler)(jwtmiddleware.NewContext(context.Background(), claims), nil) - return err - }, - "attestation": func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed string, handler middleware.Handler) error { - ctx := transport.NewServerContext(context.Background(), &fakeTransport{header: headerCarrier{authorizationHeader: {"Bearer " + signed}}}) - _, err := middleware.Chain( - attjwtmiddleware.WithJWTMulti(log.NewStdLogger(io.Discard), attjwtmiddleware.NewAPITokenProvider(signingKey)), - WithAttestationContextFromAPIToken(apiTokenUC, orgUC, logger), - )(handler)(ctx, nil) - return err - }, + {name: "a product row whose JWT names no scope", rowScope: &product, rowScopeID: &rowProduct, productClaim: rowProduct, wantErr: errNotVerified}, + {name: "the claim is on a row that records no scope", productClaim: otherProduct, wantErr: errNotVerified}, } for _, tc := range testCases { - for entry, run := range entryPoints { + for entry, run := range apiTokenEntryPoints { t.Run(entry+"/"+tc.name, func(t *testing.T) { token := &biz.APIToken{ID: uuid.New(), Name: "ci", OrganizationID: orgID, Scope: tc.rowScope, ScopeID: tc.rowScopeID} apiTokenRepo := mocks.NewAPITokenRepo(t) apiTokenRepo.On("FindByID", mock.Anything, token.ID).Return(token, nil) orgRepo := mocks.NewOrganizationRepo(t) - orgRepo.On("FindByID", mock.Anything, orgID).Return(&biz.Organization{ID: orgID.String()}, nil) + orgRepo.On("FindByID", mock.Anything, orgID).Maybe().Return(&biz.Organization{ID: orgID.String()}, nil) - apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: signingKey}, nil, nil, nil, nil) + apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: testSigningKey}, nil, nil, nil, nil) require.NoError(t, err) orgUC := biz.NewOrganizationUseCase(orgRepo, nil, nil, nil, nil, nil, nil) + jwtClaims := apitoken.CustomClaims{ + OrgID: orgID.String(), OrgName: "acme", KeyName: token.Name, + RegisteredClaims: jwt.RegisteredClaims{ID: token.ID.String(), Issuer: testIssuer, Audience: jwt.ClaimStrings{apitoken.Audience}}, + } + if tc.signScope { + jwtClaims.Scope, jwtClaims.ScopeID = string(*tc.rowScope), tc.rowScopeID.String() + } + signed, err := jwt.NewWithClaims(apitoken.SigningMethod, preProductClaimRemoval{ - CustomClaims: apitoken.CustomClaims{ - OrgID: orgID.String(), OrgName: "acme", KeyName: token.Name, - RegisteredClaims: jwt.RegisteredClaims{ID: token.ID.String(), Issuer: "test", Audience: jwt.ClaimStrings{apitoken.Audience}}, - }, - ProductID: tc.productClaim.String(), - }).SignedString([]byte(signingKey)) + CustomClaims: jwtClaims, + ProductID: tc.productClaim.String(), + }).SignedString([]byte(testSigningKey)) require.NoError(t, err) var got *entities.APIToken - err = run(apiTokenUC, orgUC, signed, func(ctx context.Context, _ interface{}) (interface{}, error) { + err = run(apiTokenUC, orgUC, signed, "", func(ctx context.Context, _ interface{}) (interface{}, error) { got = entities.CurrentAPIToken(ctx) return nil, nil }) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } require.NoError(t, err) require.NotNil(t, got) @@ -374,15 +492,10 @@ func TestAPITokenMiddlewaresIgnoreAProductClaim(t *testing.T) { } } -// A token's row decides whether it takes the instance-admin path, which reads the organization -// from the request header rather than from the token row; the JWT "scope" claim is ignored. Both -// entry points agree. +// The token's row decides whether the token takes the instance-admin path. That path reads the +// organization from the request header, not from the token row. The JWT's claims must name the same +// scope. Both entry points behave the same. func TestAPITokenMiddlewaresResolveInstanceAdminTokens(t *testing.T) { - const ( - signingKey = "test" - orgHeader = "Chainloop-Organization" - ) - logger := log.NewHelper(log.NewStdLogger(io.Discard)) rowOrg := &biz.Organization{ID: uuid.NewString(), Name: "row-org"} headerOrg := &biz.Organization{ID: uuid.NewString(), Name: "header-org"} @@ -395,48 +508,27 @@ func TestAPITokenMiddlewaresResolveInstanceAdminTokens(t *testing.T) { // header is the organization named in the request header header string wantOrg *biz.Organization + wantErr string }{ - {name: "an instance-admin token takes the organization in the header", scopeClaim: authz.ScopeInstanceAdmin, header: headerOrg.Name, wantOrg: headerOrg}, - {name: "an instance-admin token without the header has no organization", scopeClaim: authz.ScopeInstanceAdmin}, + {name: "an instance token takes the organization in the header", scopeClaim: string(authz.ResourceTypeInstance), header: headerOrg.Name, wantOrg: headerOrg}, + {name: "an instance token without the header has no organization", scopeClaim: string(authz.ResourceTypeInstance)}, + {name: "an older instance-admin token takes the organization in the header", scopeClaim: authz.ScopeInstanceAdmin, header: headerOrg.Name, wantOrg: headerOrg}, + {name: "an older instance-admin token without the header has no organization", scopeClaim: authz.ScopeInstanceAdmin}, {name: "an organization token takes its row's organization", rowOrg: rowOrg, header: headerOrg.Name, wantOrg: rowOrg}, - // The row decides, not the claim. - {name: "an instance token without the claim is instance-admin", header: headerOrg.Name, wantOrg: headerOrg}, - {name: "an organization token carrying the claim takes its row's organization", scopeClaim: authz.ScopeInstanceAdmin, rowOrg: rowOrg, header: headerOrg.Name, wantOrg: rowOrg}, - } - - type entryPoint func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed, header string, handler middleware.Handler) error - entryPoints := map[string]entryPoint{ - "API": func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed, header string, handler middleware.Handler) error { - claims := jwt.MapClaims{} - if _, _, err := jwt.NewParser().ParseUnverified(signed, claims); err != nil { - return err - } - - ctx := transport.NewServerContext(context.Background(), &fakeTransport{header: headerCarrier{orgHeader: {header}}}) - _, err := WithCurrentAPITokenAndOrgMiddleware(apiTokenUC, orgUC, logger)(handler)(jwtmiddleware.NewContext(ctx, claims), nil) - return err - }, - "attestation": func(apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, signed, header string, handler middleware.Handler) error { - ctx := transport.NewServerContext(context.Background(), &fakeTransport{header: headerCarrier{ - authorizationHeader: {"Bearer " + signed}, - orgHeader: {header}, - }}) - _, err := middleware.Chain( - attjwtmiddleware.WithJWTMulti(log.NewStdLogger(io.Discard), attjwtmiddleware.NewAPITokenProvider(signingKey)), - WithAttestationContextFromAPIToken(apiTokenUC, orgUC, logger), - )(handler)(ctx, nil) - return err - }, + // The signed claims and the row must agree, and the claims must agree with themselves. + {name: "an instance row whose JWT names no scope is refused", header: headerOrg.Name, wantErr: errNotVerified}, + {name: "an organization row whose JWT carries the instance-admin claim is refused", scopeClaim: authz.ScopeInstanceAdmin, rowOrg: rowOrg, header: headerOrg.Name, wantErr: errNotVerified}, + {name: "an organization row whose JWT names the instance scope is refused", scopeClaim: string(authz.ResourceTypeInstance), rowOrg: rowOrg, header: headerOrg.Name, wantErr: errNotVerified}, } for _, tc := range testCases { - for entry, run := range entryPoints { + for entry, run := range apiTokenEntryPoints { t.Run(entry+"/"+tc.name, func(t *testing.T) { token := &biz.APIToken{ID: uuid.New(), Name: "ci"} jwtClaims := apitoken.CustomClaims{ KeyName: token.Name, Scope: tc.scopeClaim, - RegisteredClaims: jwt.RegisteredClaims{ID: token.ID.String(), Issuer: "test", Audience: jwt.ClaimStrings{apitoken.Audience}}, + RegisteredClaims: jwt.RegisteredClaims{ID: token.ID.String(), Issuer: testIssuer, Audience: jwt.ClaimStrings{apitoken.Audience}}, } apiTokenRepo := mocks.NewAPITokenRepo(t) @@ -446,7 +538,7 @@ func TestAPITokenMiddlewaresResolveInstanceAdminTokens(t *testing.T) { token.OrganizationID = uuid.MustParse(tc.rowOrg.ID) rowOrgID = &token.OrganizationID jwtClaims.OrgID, jwtClaims.OrgName = tc.rowOrg.ID, tc.rowOrg.Name - orgRepo.On("FindByID", mock.Anything, token.OrganizationID).Return(tc.rowOrg, nil) + orgRepo.On("FindByID", mock.Anything, token.OrganizationID).Maybe().Return(tc.rowOrg, nil) } // The row records its scope: its organization's, or the instance's when it has none if rowOrgID != nil { @@ -459,11 +551,11 @@ func TestAPITokenMiddlewaresResolveInstanceAdminTokens(t *testing.T) { } apiTokenRepo.On("FindByID", mock.Anything, token.ID).Return(token, nil) - apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: signingKey}, nil, nil, nil, nil) + apiTokenUC, err := biz.NewAPITokenUseCase(apiTokenRepo, &biz.APITokenJWTConfig{SymmetricHmacKey: testSigningKey}, nil, nil, nil, nil) require.NoError(t, err) orgUC := biz.NewOrganizationUseCase(orgRepo, nil, nil, nil, nil, nil, nil) - signed, err := jwt.NewWithClaims(apitoken.SigningMethod, jwtClaims).SignedString([]byte(signingKey)) + signed, err := jwt.NewWithClaims(apitoken.SigningMethod, jwtClaims).SignedString([]byte(testSigningKey)) require.NoError(t, err) var gotOrg *entities.Org @@ -473,6 +565,10 @@ func TestAPITokenMiddlewaresResolveInstanceAdminTokens(t *testing.T) { gotOrg, gotToken, gotSubject = entities.CurrentOrg(ctx), entities.CurrentAPIToken(ctx), CurrentAuthzSubject(ctx) return nil, nil }) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } require.NoError(t, err) require.NotNil(t, gotToken) diff --git a/app/controlplane/pkg/authz/authz.go b/app/controlplane/pkg/authz/authz.go index 471338e60..89ab52bf3 100644 --- a/app/controlplane/pkg/authz/authz.go +++ b/app/controlplane/pkg/authz/authz.go @@ -103,7 +103,8 @@ const ( RoleProductViewer Role = "role:product:viewer" RoleProductAdmin Role = "role:product:admin" - // Scope for instance admin tokens + // ScopeInstanceAdmin is the scope claim of an instance token minted before the control plane + // signed the scope kind. A newer instance token has ResourceTypeInstance in that claim. ScopeInstanceAdmin = "INSTANCE_ADMIN" ) diff --git a/app/controlplane/pkg/biz/apitoken.go b/app/controlplane/pkg/biz/apitoken.go index 58b8b0481..2075a76f9 100644 --- a/app/controlplane/pkg/biz/apitoken.go +++ b/app/controlplane/pkg/biz/apitoken.go @@ -18,6 +18,7 @@ package biz import ( "bytes" "context" + "errors" "fmt" "slices" "time" @@ -34,6 +35,11 @@ import ( "github.com/google/uuid" ) +// ErrAPITokenClaimsMismatch marks a token whose row disagrees with its signed claims, or whose +// claims are malformed. Something wrote the row or the claims incorrectly. It is a security event, +// not an expired or old credential. The caller sees only this message, so it names no detail. +var ErrAPITokenClaimsMismatch = errors.New("API token could not be verified") + var apiTokenTracer = otelx.Tracer("chainloop-controlplane", "biz/apitoken") type APITokenJWTConfig struct { @@ -161,6 +167,71 @@ func (t *APIToken) IsOrgScoped() bool { return t.scopeView().IsOrgScoped() } +// VerifyClaims checks the token's row against its signed claims. The claims are the source of +// truth. VerifyClaims reads the scope from them first, and then the row must have the same scope, +// organization, project and workflow. A project or workflow that only the row or only the claims +// name is also a difference. Any difference refuses the token, so a wrong row can never widen the +// token or move it elsewhere. Every refusal for a difference wraps ErrAPITokenClaimsMismatch. +func (t *APIToken) VerifyClaims(claims *apitoken.CustomClaims) error { + if t == nil { + return errors.New("API token not found") + } + + if claims == nil { + return errors.New("API token has no claims") + } + + // A token minted before the control plane signed its scope gets the scope that its other + // claims imply: INSTANCE_ADMIN, else its project_id, else its org_id. + kind, id, err := claims.GetScope() + if err != nil { + return fmt.Errorf("API token scope claims: %w: %w", err, ErrAPITokenClaimsMismatch) + } + + if t.Scope == nil || *t.Scope != kind || !sameScopeID(t.ScopeID, id) { + return fmt.Errorf("API token scope mismatch: %w", ErrAPITokenClaimsMismatch) + } + + // An instance token has no organization, and its org_id claim is empty + orgID := "" + if t.OrganizationID != uuid.Nil { + orgID = t.OrganizationID.String() + } + + if claims.OrgID != orgID { + return fmt.Errorf("API token organization mismatch: %w", ErrAPITokenClaimsMismatch) + } + + if !sameClaimedID(t.ProjectID, claims.ProjectID) { + return fmt.Errorf("API token project mismatch: %w", ErrAPITokenClaimsMismatch) + } + + if !sameClaimedID(t.WorkflowID, claims.WorkflowID) { + return fmt.Errorf("API token workflow mismatch: %w", ErrAPITokenClaimsMismatch) + } + + return nil +} + +// sameClaimedID reports whether an optional id of the row equals its claim. An unset id equals +// an empty claim. +func sameClaimedID(row *uuid.UUID, claim string) bool { + if row == nil { + return claim == "" + } + + return row.String() == claim +} + +// sameScopeID reports whether two optional scope ids are equal. Two unset ids are equal. +func sameScopeID(a, b *uuid.UUID) bool { + if a == nil || b == nil { + return a == b + } + + return *a == *b +} + // APITokenCreateOpts is everything the repository persists for a new token. type APITokenCreateOpts struct { Name string @@ -481,14 +552,15 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description KeyID: token.ID, KeyName: name, ExpiresAt: expiresAt, + // The JWT signs the scope. The row must then match it. + Scope: scope, + ScopeID: scopeID, } - // Set org info if available or instance-level token scope + // An instance-level token has no organization if org != nil { generationOpts.OrgID = &token.OrganizationID generationOpts.OrgName = &org.Name - } else { - generationOpts.Scope = ToPtr(authz.ScopeInstanceAdmin) } if projectID != nil { @@ -522,7 +594,10 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description return token, nil } -// RegenerateJWT will regenerate a new JWT for the given token. Use with caution, since old JWTs are not invalidated. +// RegenerateJWT signs a new JWT for the given token. Use it with caution: it does not invalidate +// old JWTs. The new JWT signs the scope that the row records. RegenerateJWT refuses a row with no +// scope, or with a scope that contradicts its other columns. It still signs a row that is +// consistent but wrong, so callers must be sure that it is the token they mean. func (uc *APITokenUseCase) RegenerateJWT(ctx context.Context, tokenID uuid.UUID, expiresIn time.Duration) (*APIToken, error) { ctx, span := otelx.Start(ctx, apiTokenTracer, "APITokenUseCase.RegenerateJWT") defer span.End() @@ -538,24 +613,36 @@ func (uc *APITokenUseCase) RegenerateJWT(ctx context.Context, tokenID uuid.UUID, return nil, fmt.Errorf("finding token: %w", err) } + // Never sign a row that records no scope, or a row whose scope contradicts its other columns. + if token.Scope == nil { + return nil, NewErrValidationStr("the token records no scope") + } + + var rowOrgID *uuid.UUID + if token.OrganizationID != uuid.Nil { + rowOrgID = &token.OrganizationID + } + + if err := ValidateTokenShape(token.Scope, token.ScopeID, rowOrgID, token.ProjectID, token.ProjectIDs); err != nil { + return nil, err + } + generationOpts := &apitoken.GenerateJWTOptions{ KeyID: token.ID, KeyName: token.Name, ExpiresAt: &expiresAt, + Scope: token.Scope, + ScopeID: token.ScopeID, } - // Check if this is an org-scoped or instance-level token + // An instance-level token has no organization if token.OrganizationID != uuid.Nil { - // Org-scoped token org, err := uc.orgUseCase.FindByID(ctx, token.OrganizationID.String()) if err != nil { return nil, fmt.Errorf("finding organization: %w", err) } generationOpts.OrgID = &token.OrganizationID generationOpts.OrgName = &org.Name - } else { - // Instance-level token - generationOpts.Scope = ToPtr(authz.ScopeInstanceAdmin) } // Preserve project / workflow scope claims that the row carries. diff --git a/app/controlplane/pkg/biz/apitoken_integration_test.go b/app/controlplane/pkg/biz/apitoken_integration_test.go index 016a71345..bf4331d34 100644 --- a/app/controlplane/pkg/biz/apitoken_integration_test.go +++ b/app/controlplane/pkg/biz/apitoken_integration_test.go @@ -24,6 +24,8 @@ import ( "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz" "github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz/testhelpers" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/data/ent" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/apitoken" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" @@ -1250,35 +1252,99 @@ func (s *apiTokenTestSuite) TestCreateAProductTokenWithItsProjects() { } } -// A product token's JWT names no product, on creation or regeneration: the row decides what the -// token is confined to. -func (s *apiTokenTestSuite) TestGeneratedJWTCarriesNoProductClaim() { +// Every JWT signs the scope that its row records, when the token is created and when it is +// regenerated. The row can then only match that scope. A product token's JWT names its product only +// through that scope. +func (s *apiTokenTestSuite) TestGeneratedJWTSignsTheTokenScope() { ctx := context.Background() productID := uuid.New() - claimsOf := func(raw string) jwt.MapClaims { - claims := jwt.MapClaims{} - info, err := jwt.ParseWithClaims(raw, claims, func(_ *jwt.Token) (interface{}, error) { + wf, err := s.Workflow.Create(ctx, &biz.WorkflowCreateOpts{Name: randomName(), OrgID: s.org.ID, Project: s.p1.Name}) + s.Require().NoError(err) + + // claimsOf verifies raw. It returns the payload twice: as parsed, and as the typed claims. + claimsOf := func(raw string) (jwt.MapClaims, *apitoken.CustomClaims) { + payload := jwt.MapClaims{} + info, err := jwt.ParseWithClaims(raw, payload, func(_ *jwt.Token) (interface{}, error) { return []byte("test"), nil }) s.Require().NoError(err) s.True(info.Valid) - return claims + claims, err := apitoken.ClaimsFromMap(payload) + s.Require().NoError(err) + + return payload, claims } - token, err := s.APIToken.Create(ctx, randomName(), nil, toPtrDuration(24*time.Hour), &s.org.ID, - biz.APITokenWithScope(authz.ResourceTypeProduct, &productID), biz.APITokenWithProjectIDs(nil)) - s.Require().NoError(err) + testCases := []struct { + name string + org *string + opts []biz.APITokenCreateOpt + wantScope authz.ResourceType + wantScopeID string + }{ + {name: "organization", org: &s.org.ID, wantScope: authz.ResourceTypeOrganization, wantScopeID: s.org.ID}, + {name: string(authz.ResourceTypeProject), org: &s.org.ID, opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1)}, wantScope: authz.ResourceTypeProject, wantScopeID: s.p1.ID.String()}, + {name: "workflow-pinned", org: &s.org.ID, opts: []biz.APITokenCreateOpt{biz.APITokenWithProject(s.p1), biz.APITokenWithWorkflow(wf)}, wantScope: authz.ResourceTypeProject, wantScopeID: s.p1.ID.String()}, + {name: "product", org: &s.org.ID, opts: []biz.APITokenCreateOpt{biz.APITokenWithScope(authz.ResourceTypeProduct, &productID), biz.APITokenWithProjectIDs(nil)}, wantScope: authz.ResourceTypeProduct, wantScopeID: productID.String()}, + {name: "instance", wantScope: authz.ResourceTypeInstance}, + } - regenerated, err := s.APIToken.RegenerateJWT(ctx, token.ID, 48*time.Hour) - s.Require().NoError(err) + for _, tc := range testCases { + s.Run(tc.name, func() { + created, err := s.APIToken.Create(ctx, randomName(), nil, toPtrDuration(24*time.Hour), tc.org, tc.opts...) + s.Require().NoError(err) + regenerated, err := s.APIToken.RegenerateJWT(ctx, created.ID, 48*time.Hour) + s.Require().NoError(err) + stored, err := s.Repos.APITokenRepo.FindByID(ctx, created.ID) + s.Require().NoError(err) - for minted, raw := range map[string]string{"created": token.JWT, "regenerated": regenerated.JWT} { - claims := claimsOf(raw) - s.NotContains(claims, "product_id", minted) - s.Equal(token.ID.String(), claims["jti"], minted) - s.Equal(s.org.ID, claims["org_id"], minted) + for minted, raw := range map[string]string{"created": created.JWT, "regenerated": regenerated.JWT} { + payload, claims := claimsOf(raw) + s.Equal(string(tc.wantScope), claims.Scope, minted) + s.Equal(tc.wantScopeID, claims.ScopeID, minted) + s.NoError(stored.VerifyClaims(claims), minted) + + // What changes during a token's life never goes into the JWT + s.NotContains(payload, "project_ids", minted) + s.NotContains(payload, "policies", minted) + s.NotContains(payload, "product_id", minted) + } + }) + } +} + +// RegenerateJWT signs the scope that the row records. It refuses, and does not sign, a row that +// records no scope or that contradicts its other columns. +func (s *apiTokenTestSuite) TestRegenerateJWTRefusesARowItCannotSign() { + ctx := context.Background() + orgUUID := uuid.MustParse(s.org.ID) + + testCases := []struct { + name string + row func(*ent.APITokenCreate) *ent.APITokenCreate + }{ + {name: "a row recording no scope", row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgUUID) + }}, + {name: "an instance scope on an organization's token", row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgUUID).SetScope(authz.ResourceTypeInstance) + }}, + {name: "an organization scope on a project token", row: func(c *ent.APITokenCreate) *ent.APITokenCreate { + return c.SetOrganizationID(orgUUID).SetProjectID(s.p1.ID).SetScope(authz.ResourceTypeOrganization).SetScopeID(orgUUID) + }}, + } + + for _, tc := range testCases { + s.Run(tc.name, func() { + row, err := tc.row(s.Data.DB.APIToken.Create().SetName(randomName())).Save(ctx) + s.Require().NoError(err) + + _, err = s.APIToken.RegenerateJWT(ctx, row.ID, time.Hour) + s.Require().Error(err) + s.True(biz.IsErrValidation(err), "got %v", err) + }) } } diff --git a/app/controlplane/pkg/biz/apitoken_verify_claims_test.go b/app/controlplane/pkg/biz/apitoken_verify_claims_test.go new file mode 100644 index 000000000..eaf553c9e --- /dev/null +++ b/app/controlplane/pkg/biz/apitoken_verify_claims_test.go @@ -0,0 +1,124 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package biz + +import ( + "errors" + "testing" + + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/apitoken" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +const ( + // errScopeMismatch is the message of a row whose scope differs from the signed one. + errScopeMismatch = "scope mismatch" + // errWorkflowMismatch is the message of a row whose workflow differs from the signed one. + errWorkflowMismatch = "workflow mismatch" +) + +// The signed claims fix what a token was granted, and its row may only match them. If a row +// disagrees, VerifyClaims refuses the token. It never widens or moves the token. +func TestAPITokenVerifyClaims(t *testing.T) { + org, otherOrg := uuid.New(), uuid.New() + project, otherProject := uuid.New(), uuid.New() + product, otherProduct := uuid.New(), uuid.New() + workflow := uuid.New() + + orgKind, projectKind := authz.ResourceTypeOrganization, authz.ResourceTypeProject + productKind, instanceKind := authz.ResourceTypeProduct, authz.ResourceTypeInstance + + orgRow := &APIToken{OrganizationID: org, Scope: &orgKind, ScopeID: &org} + projectRow := &APIToken{OrganizationID: org, ProjectID: &project, Scope: &projectKind, ScopeID: &project} + workflowRow := &APIToken{OrganizationID: org, ProjectID: &project, WorkflowID: &workflow, Scope: &projectKind, ScopeID: &project} + productRow := &APIToken{OrganizationID: org, Scope: &productKind, ScopeID: &product, ProjectIDs: []uuid.UUID{project}} + instanceRow := &APIToken{Scope: &instanceKind} + + signedOrg := apitoken.CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeOrganization), ScopeID: org.String()} + signedProject := apitoken.CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeProject), ScopeID: project.String()} + signedWorkflow := apitoken.CustomClaims{OrgID: org.String(), ProjectID: project.String(), WorkflowID: workflow.String(), Scope: string(authz.ResourceTypeProject), ScopeID: project.String()} + signedProduct := apitoken.CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeProduct), ScopeID: product.String()} + legacyOrg := apitoken.CustomClaims{OrgID: org.String()} + legacyProject := apitoken.CustomClaims{OrgID: org.String(), ProjectID: project.String()} + legacyWorkflow := apitoken.CustomClaims{OrgID: org.String(), ProjectID: project.String(), WorkflowID: workflow.String()} + // widenedProjectRow is a project token's row rewritten to its whole organization + widenedProjectRow := &APIToken{OrganizationID: org, ProjectID: &project, Scope: &orgKind, ScopeID: &org} + + testCases := []struct { + name string + row *APIToken + claims apitoken.CustomClaims + wantErr string + // mismatch marks a refusal caused by a wrong row. That error must wrap + // ErrAPITokenClaimsMismatch, so that the middleware logs it as a security event. + mismatch bool + }{ + {name: "signed organization token", row: orgRow, claims: signedOrg}, + {name: "signed project token", row: projectRow, claims: signedProject}, + {name: "signed workflow-pinned token", row: workflowRow, claims: signedWorkflow}, + {name: "signed product token", row: productRow, claims: signedProduct}, + {name: "signed instance token", row: instanceRow, claims: apitoken.CustomClaims{Scope: string(authz.ResourceTypeInstance)}}, + {name: "legacy organization token", row: orgRow, claims: legacyOrg}, + {name: "legacy project token", row: projectRow, claims: legacyProject}, + {name: "legacy workflow-pinned token", row: workflowRow, claims: legacyWorkflow}, + {name: "legacy instance token", row: instanceRow, claims: apitoken.CustomClaims{Scope: authz.ScopeInstanceAdmin}}, + + {name: "a row recording no scope", row: &APIToken{OrganizationID: org}, claims: legacyOrg, wantErr: errScopeMismatch, mismatch: true}, + {name: "a signed token whose row's scope was cleared", row: &APIToken{OrganizationID: org}, claims: signedOrg, wantErr: errScopeMismatch, mismatch: true}, + // Claims without a scope name its organization, not the product that the row records + {name: "a product row whose claims name no scope", row: productRow, claims: legacyOrg, wantErr: errScopeMismatch, mismatch: true}, + {name: "malformed scope claims", row: orgRow, claims: apitoken.CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeOrganization)}, wantErr: "scope claims", mismatch: true}, + {name: "an instance-admin claim on an organization row", row: orgRow, claims: apitoken.CustomClaims{OrgID: org.String(), Scope: authz.ScopeInstanceAdmin}, wantErr: "scope claims", mismatch: true}, + {name: "a project row widened to its organization, signed claims", row: widenedProjectRow, claims: signedProject, wantErr: errScopeMismatch, mismatch: true}, + {name: "a project row widened to its organization, legacy claims", row: widenedProjectRow, claims: legacyProject, wantErr: errScopeMismatch, mismatch: true}, + {name: "an organization row recording no scope id", row: &APIToken{OrganizationID: org, Scope: &orgKind}, claims: signedOrg, wantErr: errScopeMismatch, mismatch: true}, + {name: "an organization row made an instance row, signed claims", row: &APIToken{OrganizationID: org, Scope: &instanceKind}, claims: signedOrg, wantErr: errScopeMismatch, mismatch: true}, + {name: "an organization row made an instance row, legacy claims", row: &APIToken{OrganizationID: org, Scope: &instanceKind}, claims: legacyOrg, wantErr: errScopeMismatch, mismatch: true}, + {name: "a project row moved to another project", row: &APIToken{OrganizationID: org, ProjectID: &project, Scope: &projectKind, ScopeID: &otherProject}, claims: signedProject, wantErr: errScopeMismatch, mismatch: true}, + {name: "a product row moved to another product", row: &APIToken{OrganizationID: org, Scope: &productKind, ScopeID: &otherProduct}, claims: signedProduct, wantErr: errScopeMismatch, mismatch: true}, + {name: "an organization row moved to another organization", row: &APIToken{OrganizationID: otherOrg, Scope: &orgKind, ScopeID: &otherOrg}, claims: legacyOrg, wantErr: errScopeMismatch, mismatch: true}, + {name: "a product row moved to another organization", row: &APIToken{OrganizationID: otherOrg, Scope: &productKind, ScopeID: &product}, claims: signedProduct, wantErr: "organization mismatch", mismatch: true}, + {name: "the project claim disagrees with the row's project column", row: &APIToken{OrganizationID: org, ProjectID: &otherProject, Scope: &projectKind, ScopeID: &project}, claims: signedProject, wantErr: "project mismatch", mismatch: true}, + {name: "a workflow claim on a row with no workflow", row: projectRow, claims: signedWorkflow, wantErr: errWorkflowMismatch, mismatch: true}, + {name: "a workflow on the row that the signed claims do not name", row: workflowRow, claims: signedProject, wantErr: errWorkflowMismatch, mismatch: true}, + {name: "a workflow on the row that the legacy claims do not name", row: workflowRow, claims: legacyProject, wantErr: errWorkflowMismatch, mismatch: true}, + {name: "a project on an organization row that the claims do not name", row: &APIToken{OrganizationID: org, ProjectID: &project, Scope: &orgKind, ScopeID: &org}, claims: signedOrg, wantErr: "project mismatch", mismatch: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.row.VerifyClaims(&tc.claims) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + assert.ErrorContains(t, err, tc.wantErr) + assert.Equal(t, tc.mismatch, errors.Is(err, ErrAPITokenClaimsMismatch)) + }) + } + + t.Run("no token", func(t *testing.T) { + var missing *APIToken + assert.ErrorContains(t, missing.VerifyClaims(&legacyOrg), "not found") + }) + + t.Run("no claims", func(t *testing.T) { + assert.ErrorContains(t, orgRow.VerifyClaims(nil), "no claims") + }) +} diff --git a/app/controlplane/pkg/jwt/apitoken/apitoken.go b/app/controlplane/pkg/jwt/apitoken/apitoken.go index 26ca5b948..15738b57c 100644 --- a/app/controlplane/pkg/jwt/apitoken/apitoken.go +++ b/app/controlplane/pkg/jwt/apitoken/apitoken.go @@ -16,9 +16,12 @@ package apitoken import ( + "encoding/json" "errors" + "fmt" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" ) @@ -77,7 +80,10 @@ type GenerateJWTOptions struct { WorkflowID *uuid.UUID WorkflowName *string ExpiresAt *time.Time - Scope *string + // Scope and ScopeID name the token's scope, as its row records it. Scope is required. + // ScopeID is unset only for an instance token. + Scope *authz.ResourceType + ScopeID *uuid.UUID } // GenerateJWT creates a new JWT token for the given organization and keyID @@ -109,10 +115,6 @@ func (ra *Builder) GenerateJWT(opts *GenerateJWTOptions) (string, error) { claims.OrgName = *opts.OrgName } - if opts.Scope != nil { - claims.Scope = *opts.Scope - } - if opts.ProjectID != nil { claims.ProjectID = opts.ProjectID.String() claims.ProjectName = *opts.ProjectName @@ -126,6 +128,27 @@ func (ra *Builder) GenerateJWT(opts *GenerateJWTOptions) (string, error) { claims.WorkflowName = *opts.WorkflowName } + if opts.Scope == nil { + return "", errors.New("scope is required") + } + + switch *opts.Scope { + case authz.ResourceTypeInstance, authz.ResourceTypeOrganization, authz.ResourceTypeProject, authz.ResourceTypeProduct: + default: + return "", fmt.Errorf("invalid scope %q", *opts.Scope) + } + + claims.Scope = string(*opts.Scope) + if opts.ScopeID != nil { + claims.ScopeID = opts.ScopeID.String() + } + + // Refuse claims that do not fit the scope, for example a project scope without the claim of + // that project. GetScope runs that check. + if _, _, err := claims.GetScope(); err != nil { + return "", fmt.Errorf("inconsistent token scope: %w", err) + } + // optional expiration value, i.e 30 days if opts.ExpiresAt != nil { claims.ExpiresAt = jwt.NewNumericDate(*opts.ExpiresAt) @@ -143,6 +166,139 @@ type CustomClaims struct { ProjectName string `json:"project_name,omitempty"` WorkflowID string `json:"workflow_id,omitempty"` WorkflowName string `json:"workflow_name,omitempty"` - Scope string `json:"scope,omitempty"` + // Scope and ScopeID say what the token was granted. Scope is the kind: instance, organization, + // project or product. ScopeID names the resource, and an instance scope names none. + // + // A token minted before the control plane signed its scope has no scope claim. An older + // instance token has the value "INSTANCE_ADMIN" in it instead. GetScope derives the scope of + // such a token from the claims that it does carry. + Scope string `json:"scope,omitempty"` + ScopeID string `json:"scope_id,omitempty"` jwt.RegisteredClaims } + +// HasScopeClaims reports whether the token was signed with its scope kind in the scope claim. A +// token without it was minted before the control plane signed the scope. +func (c *CustomClaims) HasScopeClaims() bool { + return c.Scope != "" && c.Scope != authz.ScopeInstanceAdmin +} + +// GetScope returns the scope that the claims bind the token to. For a token with the scope and +// scope_id claims, that scope is what they name. For an older token, it is the scope that its +// other claims imply (see legacyScope). GetScope returns an error for claims that do not fit the +// scope (see validateScope). +func (c *CustomClaims) GetScope() (authz.ResourceType, *uuid.UUID, error) { + kind, id, err := c.namedScope() + if err != nil { + return "", nil, err + } + + if err := c.validateScope(kind, id); err != nil { + return "", nil, err + } + + return kind, id, nil +} + +// namedScope is the scope the scope and scope_id claims name, else the one the claims of an older +// token imply. +func (c *CustomClaims) namedScope() (authz.ResourceType, *uuid.UUID, error) { + if !c.HasScopeClaims() { + return c.legacyScope() + } + + kind := authz.ResourceType(c.Scope) + switch kind { + case authz.ResourceTypeInstance: + if c.ScopeID != "" { + return "", nil, errors.New("an instance scope names no resource") + } + + return kind, nil, nil + case authz.ResourceTypeOrganization, authz.ResourceTypeProject, authz.ResourceTypeProduct: + id, err := uuid.Parse(c.ScopeID) + if err != nil { + return "", nil, fmt.Errorf("invalid scope_id claim: %w", err) + } + + return kind, &id, nil + default: + return "", nil, fmt.Errorf("unknown scope claim %q", c.Scope) + } +} + +// legacyScope returns the scope that the claims of an older token imply. An older token is a token +// minted before the control plane signed the scope. Its scope is the instance for the +// instance-admin value, else its project, else its organization. biz.newTokenScope and the scope backfill +// migration apply the same rule to the row, so the two agree. +func (c *CustomClaims) legacyScope() (authz.ResourceType, *uuid.UUID, error) { + var kind authz.ResourceType + var raw string + switch { + case c.Scope == authz.ScopeInstanceAdmin: + return authz.ResourceTypeInstance, nil, nil + case c.ProjectID != "": + kind, raw = authz.ResourceTypeProject, c.ProjectID + case c.OrgID != "": + kind, raw = authz.ResourceTypeOrganization, c.OrgID + default: + return "", nil, errors.New("the claims name no scope") + } + + id, err := uuid.Parse(raw) + if err != nil { + return "", nil, fmt.Errorf("invalid %s claim: %w", kind, err) + } + + return kind, &id, nil +} + +// validateScope checks that the other claims fit the scope. This makes the control plane and the +// platform read the same scope from the token. The rules are: +// - An instance token names no organization and no project. +// - An organization token names its own organization and no project. +// - A project token names its organization and the project of its scope. +// - A product token names its organization and no project. +// - A workflow claim always comes with a project claim. +func (c *CustomClaims) validateScope(kind authz.ResourceType, id *uuid.UUID) error { + if c.WorkflowID != "" && c.ProjectID == "" { + return errors.New("a workflow claim needs a project claim") + } + + switch kind { + case authz.ResourceTypeInstance: + if c.OrgID != "" || c.ProjectID != "" { + return errors.New("an instance scope names no organization or project") + } + case authz.ResourceTypeOrganization: + if c.OrgID != id.String() || c.ProjectID != "" { + return errors.New("an organization scope names its own organization and no project") + } + case authz.ResourceTypeProject: + if c.OrgID == "" || c.ProjectID != id.String() { + return errors.New("a project scope names its organization and its own project") + } + case authz.ResourceTypeProduct: + if c.OrgID == "" || c.ProjectID != "" { + return errors.New("a product scope names its organization and no project") + } + } + + return nil +} + +// ClaimsFromMap reads API-token claims that were parsed generically, as the API entry point +// receives them. A claim of the wrong type is an error, not an absent claim. +func ClaimsFromMap(m jwt.MapClaims) (*CustomClaims, error) { + raw, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("encoding claims: %w", err) + } + + claims := &CustomClaims{} + if err := json.Unmarshal(raw, claims); err != nil { + return nil, fmt.Errorf("decoding claims: %w", err) + } + + return claims, nil +} diff --git a/app/controlplane/pkg/jwt/apitoken/apitoken_test.go b/app/controlplane/pkg/jwt/apitoken/apitoken_test.go index f026a9940..66a542c8b 100644 --- a/app/controlplane/pkg/jwt/apitoken/apitoken_test.go +++ b/app/controlplane/pkg/jwt/apitoken/apitoken_test.go @@ -19,6 +19,7 @@ import ( "testing" "time" + "github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -68,85 +69,109 @@ func TestNewBuilder(t *testing.T) { } } +const ( + testKeyName = "key-name" + claimJTI = "jti" +) + func TestGenerateJWT(t *testing.T) { const hmacSecret = "my-secret" + org := uuid.MustParse("123e4567-e89b-12d3-a456-426614174000") + project := uuid.MustParse("223e4567-e89b-12d3-a456-426614174000") + workflow := uuid.MustParse("323e4567-e89b-12d3-a456-426614174000") + product := uuid.MustParse("423e4567-e89b-12d3-a456-426614174000") + keyID := uuid.MustParse("523e4567-e89b-12d3-a456-426614174000") + orgScope, projectScope := authz.ResourceTypeOrganization, authz.ResourceTypeProject + productScope, instanceScope := authz.ResourceTypeProduct, authz.ResourceTypeInstance + testCases := []struct { name string opts *GenerateJWTOptions wantErr bool }{ { - name: "no project", - opts: &GenerateJWTOptions{ - OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - OrgName: toPtr("org-name"), - KeyName: "key-name", - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - }, + name: "organization token", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), Scope: &orgScope, ScopeID: &org}, }, { name: "no expiration", - opts: &GenerateJWTOptions{ - OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - OrgName: toPtr("org-name"), - KeyName: "key-name", - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - }, + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + Scope: &orgScope, ScopeID: &org}, }, { - name: "with project", - opts: &GenerateJWTOptions{ - OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - OrgName: toPtr("org-name"), - KeyName: "key-name", - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - ProjectID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - ProjectName: toPtr("project-name"), - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - }, + name: "project token", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + ProjectID: &project, ProjectName: toPtr("project-name"), ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), + Scope: &projectScope, ScopeID: &project}, }, { - name: "with workflow", - opts: &GenerateJWTOptions{ - OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - OrgName: toPtr("org-name"), - KeyName: "key-name", - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - ProjectID: toPtr(uuid.MustParse("223e4567-e89b-12d3-a456-426614174000")), - ProjectName: toPtr("project-name"), - WorkflowID: toPtr(uuid.MustParse("323e4567-e89b-12d3-a456-426614174000")), - WorkflowName: toPtr("workflow-name"), - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - }, + name: "workflow-pinned token", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + ProjectID: &project, ProjectName: toPtr("project-name"), WorkflowID: &workflow, WorkflowName: toPtr("workflow-name"), + ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), Scope: &projectScope, ScopeID: &project}, }, { - name: "instance token - no orgID or orgName", - opts: &GenerateJWTOptions{ - KeyName: "key-name", - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - Scope: toPtr("INSTANCE_ADMIN"), - }, + name: "product token", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + Scope: &productScope, ScopeID: &product}, + }, + { + name: "instance token", + opts: &GenerateJWTOptions{KeyName: testKeyName, KeyID: keyID, ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), + Scope: &instanceScope}, }, { name: "missing keyID", - opts: &GenerateJWTOptions{ - OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - OrgName: toPtr("org-name"), - KeyName: "key-name", - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - }, + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, + Scope: &orgScope, ScopeID: &org}, wantErr: true, }, { name: "missing keyName", - opts: &GenerateJWTOptions{ - OrgID: toPtr(uuid.MustParse("123e4567-e89b-12d3-a456-426614174000")), - OrgName: toPtr("org-name"), - KeyID: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), - ExpiresAt: toPtr(time.Now().Add(1 * time.Hour)), - }, + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyID: keyID, + Scope: &orgScope, ScopeID: &org}, + wantErr: true, + }, + { + name: "a scope id without a scope", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, ScopeID: &org}, + wantErr: true, + }, + { + name: "missing scope", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID}, + wantErr: true, + }, + { + // An empty scope would sign a token without the scope claims, like an older token + name: "an empty scope", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + Scope: toPtr(authz.ResourceType("")), ScopeID: &org}, + wantErr: true, + }, + { + name: "an organization scope naming another organization", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + Scope: &orgScope, ScopeID: &product}, + wantErr: true, + }, + { + // The older instance-admin value would read as a token minted before the scope was signed + name: "the older instance-admin value as the scope", + opts: &GenerateJWTOptions{KeyName: testKeyName, KeyID: keyID, Scope: toPtr(authz.ResourceType(authz.ScopeInstanceAdmin))}, + wantErr: true, + }, + { + name: "an instance scope naming an organization", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + Scope: &instanceScope}, + wantErr: true, + }, + { + name: "a project scope naming another project", + opts: &GenerateJWTOptions{OrgID: &org, OrgName: toPtr("org-name"), KeyName: testKeyName, KeyID: keyID, + ProjectID: &project, ProjectName: toPtr("project-name"), Scope: &projectScope, ScopeID: &product}, wantErr: true, }, } @@ -203,10 +228,11 @@ func TestGenerateJWT(t *testing.T) { assert.Empty(t, claims.WorkflowName) } - if tc.opts.Scope != nil { - assert.Equal(t, *tc.opts.Scope, claims.Scope) + assert.Equal(t, string(*tc.opts.Scope), claims.Scope) + if tc.opts.ScopeID != nil { + assert.Equal(t, tc.opts.ScopeID.String(), claims.ScopeID) } else { - assert.Empty(t, claims.Scope) + assert.Empty(t, claims.ScopeID) } if tc.opts.ExpiresAt != nil { @@ -218,6 +244,108 @@ func TestGenerateJWT(t *testing.T) { } } +func TestGetScope(t *testing.T) { + org, project, product, other := uuid.New(), uuid.New(), uuid.New(), uuid.New() + workflow := uuid.New() + + testCases := []struct { + name string + claims CustomClaims + wantKind authz.ResourceType + wantID *uuid.UUID + wantErr bool + }{ + {name: "signed organization scope", claims: CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeOrganization), ScopeID: org.String()}, wantKind: authz.ResourceTypeOrganization, wantID: &org}, + {name: "signed project scope", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeProject), ScopeID: project.String()}, wantKind: authz.ResourceTypeProject, wantID: &project}, + {name: "signed workflow-pinned scope", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), WorkflowID: workflow.String(), Scope: string(authz.ResourceTypeProject), ScopeID: project.String()}, wantKind: authz.ResourceTypeProject, wantID: &project}, + {name: "signed product scope", claims: CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeProduct), ScopeID: product.String()}, wantKind: authz.ResourceTypeProduct, wantID: &product}, + {name: "signed instance scope", claims: CustomClaims{Scope: string(authz.ResourceTypeInstance)}, wantKind: authz.ResourceTypeInstance}, + {name: "legacy instance-admin token", claims: CustomClaims{Scope: authz.ScopeInstanceAdmin}, wantKind: authz.ResourceTypeInstance}, + {name: "legacy project token", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String()}, wantKind: authz.ResourceTypeProject, wantID: &project}, + {name: "legacy workflow-pinned token", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), WorkflowID: workflow.String()}, wantKind: authz.ResourceTypeProject, wantID: &project}, + {name: "legacy organization token", claims: CustomClaims{OrgID: org.String()}, wantKind: authz.ResourceTypeOrganization, wantID: &org}, + + // Malformed scope claims + {name: "an instance scope naming a resource", claims: CustomClaims{Scope: string(authz.ResourceTypeInstance), ScopeID: org.String()}, wantErr: true}, + {name: "a resource scope without an id", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeProject)}, wantErr: true}, + {name: "a scope id that is not a uuid", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeProject), ScopeID: "nope"}, wantErr: true}, + {name: "a scope no token has", claims: CustomClaims{OrgID: org.String(), Scope: "group", ScopeID: org.String()}, wantErr: true}, + {name: "legacy claims naming nothing", claims: CustomClaims{}, wantErr: true}, + {name: "a legacy project claim that is not a uuid", claims: CustomClaims{OrgID: org.String(), ProjectID: "nope"}, wantErr: true}, + + // Claims that contradict themselves: every reader must see the same scope + {name: "an instance scope naming an organization", claims: CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeInstance)}, wantErr: true}, + {name: "a legacy instance-admin claim naming an organization", claims: CustomClaims{OrgID: org.String(), Scope: authz.ScopeInstanceAdmin}, wantErr: true}, + {name: "an organization scope naming another organization", claims: CustomClaims{OrgID: org.String(), Scope: string(authz.ResourceTypeOrganization), ScopeID: other.String()}, wantErr: true}, + {name: "an organization scope with a project claim", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeOrganization), ScopeID: org.String()}, wantErr: true}, + {name: "a project scope naming another project", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeProject), ScopeID: other.String()}, wantErr: true}, + {name: "a project scope without an organization", claims: CustomClaims{ProjectID: project.String(), Scope: string(authz.ResourceTypeProject), ScopeID: project.String()}, wantErr: true}, + {name: "a product scope with a project claim", claims: CustomClaims{OrgID: org.String(), ProjectID: project.String(), Scope: string(authz.ResourceTypeProduct), ScopeID: product.String()}, wantErr: true}, + {name: "a product scope without an organization", claims: CustomClaims{Scope: string(authz.ResourceTypeProduct), ScopeID: product.String()}, wantErr: true}, + {name: "a workflow claim without a project claim", claims: CustomClaims{OrgID: org.String(), WorkflowID: workflow.String(), Scope: string(authz.ResourceTypeOrganization), ScopeID: org.String()}, wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + kind, id, err := tc.claims.GetScope() + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantKind, kind) + assert.Equal(t, tc.wantID, id) + }) + } +} + +func TestClaimsFromMap(t *testing.T) { + testCases := []struct { + name string + in jwt.MapClaims + want *CustomClaims + wantErr bool + }{ + { + name: "every claim is read", + in: jwt.MapClaims{ + claimJTI: "id", "aud": []any{Audience}, "org_id": "o", "org_name": "on", "token_name": "t", + "project_id": "p", "workflow_id": "w", "scope": "project", "scope_id": "s", + }, + want: &CustomClaims{ + OrgID: "o", OrgName: "on", KeyName: "t", ProjectID: "p", WorkflowID: "w", + Scope: string(authz.ResourceTypeProject), ScopeID: "s", + RegisteredClaims: jwt.RegisteredClaims{ID: "id", Audience: jwt.ClaimStrings{Audience}}, + }, + }, + { + name: "a single audience string is read", + in: jwt.MapClaims{claimJTI: "id", "aud": Audience}, + want: &CustomClaims{RegisteredClaims: jwt.RegisteredClaims{ID: "id", Audience: jwt.ClaimStrings{Audience}}}, + }, + { + // The API entry point used to drop such a claim silently and skip its cross-check + name: "a claim of the wrong type is refused", + in: jwt.MapClaims{claimJTI: "id", "project_id": 42}, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + got, err := ClaimsFromMap(tc.in) + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + func toPtr[T any](t T) *T { return &t } diff --git a/app/controlplane/pkg/usercontext/entities/apitoken.go b/app/controlplane/pkg/usercontext/entities/apitoken.go index d2fe1854d..93ac5471a 100644 --- a/app/controlplane/pkg/usercontext/entities/apitoken.go +++ b/app/controlplane/pkg/usercontext/entities/apitoken.go @@ -36,8 +36,8 @@ type APIToken struct { WorkflowName *string // ACL policies for this token. Used for authorization checks. Policies []*authz.Policy - // Scope and ScopeID name what the token is scoped to. They are loaded from the row, never - // from a claim. Every row records them; a token recording none is confined to nothing. + // Scope and ScopeID name the token's scope. They come from the row, after the middleware checks + // the row against the signed claims. The middleware refuses a row that records no scope. Scope *authz.ResourceType ScopeID *uuid.UUID // ProjectIDs are the projects a product token reaches, loaded from the row.