Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion app/controlplane/internal/service/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"time"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/authz"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
errors "github.com/go-kratos/kratos/v2/errors"
Expand Down Expand Up @@ -53,6 +54,13 @@ func (s *APITokenService) Create(ctx context.Context, req *pb.APITokenServiceCre
return nil, errors.BadRequest("invalid", "project is required")
}

// Org-level API tokens can only create project-scoped tokens
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
Comment thread
migmartri marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is something that could be done with Enforce otherwise the policies are worthless no?

if !req.ProjectReference.IsSet() {
return nil, errors.Forbidden("forbidden", "org-level API tokens must specify a project when creating new tokens")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so this special case is needed because we now allow org tokens to pass.

}
}

// if the project is provided we make sure it exists and the user has permission to it
var project *biz.Project
if req.ProjectReference.IsSet() {
Expand Down Expand Up @@ -100,7 +108,13 @@ func (s *APITokenService) List(ctx context.Context, req *pb.APITokenServiceListR
defaultProjectFilter = []uuid.UUID{project.ID}
}

tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(mapTokenScope(req.Scope)))
// Org-level API tokens can only see project-scoped tokens

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we can simplify all of this with a new resource "ProjectApiToken" and enforcing that specific permission.

scope := mapTokenScope(req.Scope)
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
scope = biz.APITokenScopeProject
}

tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, biz.WithAPITokenStatusFilter(mapTokenStatusFilter(req.GetStatusFilter())), biz.WithAPITokenProjectFilter(defaultProjectFilter), biz.WithAPITokenScope(scope))
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
Expand Down Expand Up @@ -151,6 +165,13 @@ func (s *APITokenService) Revoke(ctx context.Context, req *pb.APITokenServiceRev
return nil, errors.BadRequest("invalid", "you can not manage a global API token")
}

// Org-level API tokens cannot revoke other org-level tokens
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
if t.ProjectID == nil {
return nil, errors.Forbidden("forbidden", "org-level API tokens cannot revoke org-level tokens")
}
}

// Make sure the user has permission to revoke the token in the project
if t.ProjectID != nil {
if err := s.authorizeResource(ctx, authz.PolicyAPITokenRevoke, authz.ResourceTypeProject, *t.ProjectID); err != nil {
Expand Down
132 changes: 132 additions & 0 deletions app/controlplane/internal/service/apitoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//
// 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 service

import (
"context"
"testing"

pb "github.com/chainloop-dev/chainloop/app/controlplane/api/controlplane/v1"
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/entities"
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/biz"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)

func TestAPITokenService_Create_OrgTokenWithoutProjectIsRejected(t *testing.T) {
t.Parallel()

svc := &APITokenService{service: newService()}

ctx := context.Background()
ctx = entities.WithCurrentOrg(ctx, &entities.Org{ID: uuid.NewString()})
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: uuid.NewString(), ProjectID: nil})

req := &pb.APITokenServiceCreateRequest{Name: "test-token"}

_, err := svc.Create(ctx, req)
assert.Error(t, err)
assert.Contains(t, err.Error(), "org-level API tokens must specify a project")
}

func TestAPITokenService_List_OrgTokenForcesProjectScope(t *testing.T) {
t.Parallel()

tests := []struct {
name string
token *entities.APIToken
wantScope biz.APITokenScope
}{
{
name: "org-level token forces project scope",
token: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil},
wantScope: biz.APITokenScopeProject,
},
{
name: "project-scoped token does not override scope",
token: &entities.APIToken{ID: uuid.NewString(), ProjectID: toUUIDPtr(uuid.New())},
wantScope: "", // mapTokenScope returns "" for SCOPE_UNSPECIFIED
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
ctx = entities.WithCurrentAPIToken(ctx, tc.token)

scope := mapTokenScope(pb.APITokenServiceListRequest_SCOPE_UNSPECIFIED)
Comment thread
Piskoo marked this conversation as resolved.
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
scope = biz.APITokenScopeProject
}

assert.Equal(t, tc.wantScope, scope)
})
}
}

func TestAPITokenService_Revoke_OrgTokenCannotRevokeOrgTokens(t *testing.T) {
t.Parallel()

orgID := uuid.NewString()

tests := []struct {
name string
callerToken *entities.APIToken
targetToken *biz.APIToken
wantForbidden bool
}{
{
name: "org-level token revoking org-level token is forbidden",
callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil},
targetToken: &biz.APIToken{
ID: uuid.New(),
OrganizationID: uuid.MustParse(orgID),
ProjectID: nil,
},
wantForbidden: true,
},
{
name: "org-level token revoking project token is allowed",
callerToken: &entities.APIToken{ID: uuid.NewString(), ProjectID: nil},
targetToken: &biz.APIToken{
ID: uuid.New(),
OrganizationID: uuid.MustParse(orgID),
ProjectID: toUUIDPtr(uuid.New()),
},
wantForbidden: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
ctx = entities.WithCurrentAPIToken(ctx, tc.callerToken)

forbidden := false
if token := entities.CurrentAPIToken(ctx); token != nil && token.ProjectID == nil {
if tc.targetToken.ProjectID == nil {
forbidden = true
}
}

assert.Equal(t, tc.wantForbidden, forbidden)
})
}
}

func toUUIDPtr(id uuid.UUID) *uuid.UUID {
return &id
}
13 changes: 13 additions & 0 deletions app/controlplane/pkg/biz/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ type APITokenJWTConfig struct {
SymmetricHmacKey string
}

// orgLevelTokenPolicies are additional policies granted only to org-level tokens.
// They allow managing project-scoped tokens.
var orgLevelTokenPolicies = []*authz.Policy{
authz.PolicyAPITokenCreate,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd call this PolicyProjectLevelAPITokenCreate since it's only project level tokens

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was mentioning this above. We can create this specific resource, and then calling the enforcer directly from the service (instead of checking token!=nil && token.ProjectId == nil ...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this one is needed for the middleware to let the request pass.

authz.PolicyAPITokenList,
authz.PolicyAPITokenRevoke,
}

// APIToken is used for unattended access to the control plane API.
type APIToken struct {
ID uuid.UUID
Expand Down Expand Up @@ -204,6 +212,11 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
policies = uc.DefaultAuthzPolicies
}

// Org-level tokens additionally get project-level API token management policies
if projectID == nil && orgUUID != nil {
policies = append(policies, orgLevelTokenPolicies...)
}

// NOTE: the expiration time is stored just for reference, it's also encoded in the JWT
// We store it since Chainloop will not have access to the JWT to check the expiration once created
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID, policies)
Expand Down
64 changes: 47 additions & 17 deletions app/controlplane/pkg/biz/apitoken_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"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/golang-jwt/jwt/v4"
Expand Down Expand Up @@ -144,26 +145,55 @@ func (s *apiTokenTestSuite) TestCreate() {
}

func (s *apiTokenTestSuite) TestAuthzPolicies() {
// a new token has a new set of policies associated
token, err := s.APIToken.Create(context.Background(), randomName(), nil, nil, &s.org.ID)
require.NoError(s.T(), err)
ctx := context.Background()

// With the new architecture, API token policies are stored in the database, not in Casbin
// Verify that the token has the default policies stored
s.Require().NotNil(token.Policies)
s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies))

// Check that all default policies are present
for _, expectedPolicy := range s.APIToken.DefaultAuthzPolicies {
found := false
for _, actualPolicy := range token.Policies {
if actualPolicy.Resource == expectedPolicy.Resource && actualPolicy.Action == expectedPolicy.Action {
found = true
break
s.Run("project-level token gets default policies", func() {
token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID, biz.APITokenWithProject(s.p1))
require.NoError(s.T(), err)
s.Require().NotNil(token.Policies)
Comment thread
Piskoo marked this conversation as resolved.
s.Len(token.Policies, len(s.APIToken.DefaultAuthzPolicies))

for _, p := range s.APIToken.DefaultAuthzPolicies {
found := false
for _, actual := range token.Policies {
if actual.Resource == p.Resource && actual.Action == p.Action {
found = true
break
}
}
s.True(found, fmt.Sprintf("policy %s:%s not found", p.Resource, p.Action))
}
s.True(found, fmt.Sprintf("policy %s:%s not found", expectedPolicy.Resource, expectedPolicy.Action))
}
})

s.Run("org-level token gets default plus token management policies", func() {
token, err := s.APIToken.Create(ctx, randomName(), nil, nil, &s.org.ID)
require.NoError(s.T(), err)
s.Require().NotNil(token.Policies)

// All default policies must be present
for _, p := range s.APIToken.DefaultAuthzPolicies {
found := false
for _, actual := range token.Policies {
if actual.Resource == p.Resource && actual.Action == p.Action {
found = true
break
}
}
s.True(found, fmt.Sprintf("default policy %s:%s not found", p.Resource, p.Action))
}

// Additional org-level token management policies must be present
for _, p := range []*authz.Policy{authz.PolicyAPITokenCreate, authz.PolicyAPITokenList, authz.PolicyAPITokenRevoke} {
found := false
for _, actual := range token.Policies {
if actual.Resource == p.Resource && actual.Action == p.Action {
found = true
break
}
}
s.True(found, fmt.Sprintf("org policy %s:%s not found", p.Resource, p.Action))
}
})
}

func (s *apiTokenTestSuite) TestRevoke() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Add API token management policies to existing org-level tokens.
UPDATE "api_tokens"
SET "policies" = "policies" || '[
{"Resource": "api_token", "Action": "create"},
{"Resource": "api_token", "Action": "list"},
{"Resource": "api_token", "Action": "delete"}
]'::jsonb
WHERE "policies" IS NOT NULL
AND "organization_id" IS NOT NULL
AND "project_id" IS NULL;
3 changes: 2 additions & 1 deletion app/controlplane/pkg/data/ent/migrate/migrations/atlas.sum
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4=
h1:SDVo/094SOGNvK1KK35O4KpUY78MuSTZljVN58UGkO0=
20230706165452_init-schema.sql h1:VvqbNFEQnCvUVyj2iDYVQQxDM0+sSXqocpt/5H64k8M=
20230710111950-cas-backend.sql h1:A8iBuSzZIEbdsv9ipBtscZQuaBp3V5/VMw7eZH6GX+g=
20230712094107-cas-backends-workflow-runs.sql h1:a5rzxpVGyd56nLRSsKrmCFc9sebg65RWzLghKHh5xvI=
Expand Down Expand Up @@ -125,3 +125,4 @@ h1:zokBUIf1GE1Ih+WH8XycjROBmH2uLn+Kh6sw9vVhTl4=
20260112115927.sql h1:/RKhzT5dRphgeBitxBfo3a3fqLVgvmVZxxqe9fH8lkg=
20260204113827.sql h1:rlJNf8QRfqOfDHf2GUi+59Rgv2BkSbMTPuMalPsMkZg=
20260211225609.sql h1:DTkyg3oZSV99uPGl+vOuK9FSlEumXwoYCgchUhsg/P4=
20260303120000.sql h1:msXy2MRkzMOGxWbG1NOHh+PN5qjaBZcRzVT+7SFIwaA=
Loading