Skip to content
6 changes: 3 additions & 3 deletions app/controlplane/internal/service/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func (s *ProjectService) APITokenCreate(ctx context.Context, req *pb.ProjectServ
*expiresIn = req.ExpiresIn.AsDuration()
}

token, err := s.APITokenUseCase.Create(ctx, req.Name, req.Description, expiresIn, currentOrg.ID, biz.APITokenWithProjectID(project.ID))
token, err := s.APITokenUseCase.Create(ctx, req.Name, req.Description, expiresIn, currentOrg.ID, biz.APITokenWithProject(project))
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
Expand All @@ -81,7 +81,7 @@ func (s *ProjectService) APITokenList(ctx context.Context, req *pb.ProjectServic
return nil, err
}

tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, req.IncludeRevoked, biz.APITokenWithProjectID(project.ID))
tokens, err := s.APITokenUseCase.List(ctx, currentOrg.ID, req.IncludeRevoked, biz.APITokenWithProject(project))
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
Expand All @@ -106,7 +106,7 @@ func (s *ProjectService) APITokenRevoke(ctx context.Context, req *pb.ProjectServ
return nil, err
}

t, err := s.APITokenUseCase.FindByNameInOrg(ctx, currentOrg.ID, req.Name, biz.APITokenWithProjectID(project.ID))
t, err := s.APITokenUseCase.FindByNameInOrg(ctx, currentOrg.ID, req.Name, biz.APITokenWithProject(project))
if err != nil {
return nil, handleUseCaseErr(err, s.log)
}
Expand Down
41 changes: 38 additions & 3 deletions app/controlplane/internal/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package service

import (
"context"
"fmt"
"io"

"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext"
Expand Down Expand Up @@ -163,18 +164,35 @@ func (s *service) authorizeResource(ctx context.Context, op *authz.Policy, resou
return nil
}

// Apply RBAC
// 1 - Authorize using API token
// For now we only support API tokens to authorize project resourceTypes
// NOTE we do not run s.enforcer here because API tokens do not have roles associated with resourceTypes
// the authorization has happened at the API level and we do not have attribute-based policies in casbin yet
if token := entities.CurrentAPIToken(ctx); token != nil {
if resourceType == authz.ResourceTypeProject && token.ProjectID != nil && token.ProjectID.String() == resourceID.String() {
s.log.Debugw("msg", "authorized using API token", "resource_id", resourceID.String(), "resource_type", resourceType, "token_name", token.Name, "token_id", token.ID)
return nil
}

return errors.Forbidden("forbidden", fmt.Errorf("operation not allowed: This auth token is valid only with the project %q", *token.ProjectName).Error())
}

// 2 - We are a user
// find the resource membership that matches the resource type and ID
// for example admin in project1, then apply RBAC enforcement
m := entities.CurrentMembership(ctx)
// check for specific resource role
for _, rm := range m.Resources {
if rm.ResourceType == resourceType && rm.ResourceID == resourceID {
pass, err := s.enforcer.Enforce(string(rm.Role), op)
if err != nil {
return handleUseCaseErr(err, s.log)
}

if !pass {
return errors.Forbidden("forbidden", "operation not allowed")
}

s.log.Debugw("msg", "authorized using user membership", "resource_id", resourceID.String(), "resource_type", resourceType, "role", rm.Role, "membership_id", rm.MembershipID, "user_id", m.UserID)
return nil
}
}
Expand Down Expand Up @@ -212,6 +230,15 @@ func (s *service) visibleProjects(ctx context.Context) []uuid.UUID {

projects := make([]uuid.UUID, 0)

// 1 - Check if we are using an API token
if token := entities.CurrentAPIToken(ctx); token != nil {
if token.ProjectID != nil {
projects = append(projects, *token.ProjectID)
}
return projects
}

// 2 - We are a user
m := entities.CurrentMembership(ctx)
for _, rm := range m.Resources {
if rm.ResourceType == authz.ResourceTypeProject {
Expand All @@ -222,8 +249,16 @@ func (s *service) visibleProjects(ctx context.Context) []uuid.UUID {
return projects
}

// RBAC feature is enabled if the user has the `Org Member` role.
// RBAC feature is enabled if we are using a project scoped token or
// it is a user with org role member
func rbacEnabled(ctx context.Context) bool {
// it's an API token
token := entities.CurrentAPIToken(ctx)
if token != nil {
return token.ProjectID != nil
}

// we have an user
currentSubject := usercontext.CurrentAuthzSubject(ctx)
return currentSubject == string(authz.RoleOrgMember)
}
Expand Down
2 changes: 1 addition & 1 deletion app/controlplane/internal/service/workflow.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024 The Chainloop Authors.
// Copyright 2024-2025 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.
Expand Down
26 changes: 21 additions & 5 deletions app/controlplane/internal/usercontext/apitoken_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,15 @@ func WithCurrentAPITokenAndOrgMiddleware(apiTokenUC *biz.APITokenUseCase, orgUC
return nil, errors.New("error mapping the API-token claims")
}

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID)
// Project ID is optional
projectID, _ := genericClaims["project_id"].(string)

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, projectID)
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")
logger.Infow("msg", "[authN] processed credentials", "id", tokenID, "type", "API-token", "projectID", projectID)
}

return handler(ctx, req)
Expand Down Expand Up @@ -120,7 +123,7 @@ func WithAttestationContextFromAPIToken(apiTokenUC *biz.APITokenUseCase, orgUC *
return nil, fmt.Errorf("error extracting organization from APIToken: %w", err)
}

ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID)
ctx, err = setCurrentOrgAndAPIToken(ctx, apiTokenUC, orgUC, tokenID, claims.ProjectID)
if err != nil {
return nil, fmt.Errorf("error setting current org and user: %w", err)
}
Expand Down Expand Up @@ -157,7 +160,7 @@ func setRobotAccountFromAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUs
}

// Set the current organization and API-Token in the context
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID string) (context.Context, error) {
func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCase, orgUC *biz.OrganizationUseCase, tokenID, projectIDInClaim string) (context.Context, error) {
if tokenID == "" {
return nil, errors.New("error retrieving the key ID from the API token")
}
Expand All @@ -170,6 +173,11 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa
return 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 != "" && token.ProjectID.String() != projectIDInClaim {
return nil, errors.New("API token project mismatch")
}

// 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 {
Expand All @@ -186,7 +194,15 @@ func setCurrentOrgAndAPIToken(ctx context.Context, apiTokenUC *biz.APITokenUseCa

// Set the current organization and API-Token in the context
ctx = entities.WithCurrentOrg(ctx, &entities.Org{Name: org.Name, ID: org.ID, CreatedAt: org.CreatedAt})
ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{ID: token.ID.String(), CreatedAt: token.CreatedAt, Token: token.JWT})

ctx = entities.WithCurrentAPIToken(ctx, &entities.APIToken{
ID: token.ID.String(),
Name: token.Name,
CreatedAt: token.CreatedAt,
Token: token.JWT,
ProjectID: token.ProjectID,
Copy link
Member

Choose a reason for hiding this comment

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

If the token has a ProjectID, is this change checking that the ProjectID in the database is the same?

Copy link
Member Author

Choose a reason for hiding this comment

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

mm, it is not, let me add it

Copy link
Member Author

@migmartri migmartri Jun 27, 2025

Choose a reason for hiding this comment

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

Good catch, I was using the projectID from the DB when in reality we need to use the one from the token.

Now I check that both match

ProjectName: token.ProjectName,
})

// Set the authorization subject that will be used to check the policies
subjectAPIToken := authz.SubjectAPIToken{ID: token.ID.String()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,11 @@ func setCurrentMembershipsForUser(ctx context.Context, u *entities.User, members
Role: m.Role,
ResourceType: m.ResourceType,
ResourceID: m.ResourceID,
MembershipID: m.ID,
})
}

membership = &entities.Membership{Resources: resourceMemberships}
membership = &entities.Membership{UserID: uuid.MustParse(u.ID), Resources: resourceMemberships}
membershipsCache.Add(u.ID, membership)
}

Expand Down
14 changes: 10 additions & 4 deletions app/controlplane/internal/usercontext/entities/apitoken.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2024 The Chainloop Authors.
// Copyright 2024-2025 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.
Expand All @@ -18,12 +18,18 @@ package entities
import (
"context"
"time"

"github.com/google/uuid"
)

type APIToken struct {
ID string
CreatedAt *time.Time
Token string
ID string
// Token Name
Name string
CreatedAt *time.Time
Token string
ProjectID *uuid.UUID
ProjectName *string
}

func WithCurrentAPIToken(ctx context.Context, token *APIToken) context.Context {
Expand Down
2 changes: 2 additions & 0 deletions app/controlplane/internal/usercontext/entities/memberships.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import (
)

type Membership struct {
UserID uuid.UUID
Resources []*ResourceMembership
}

type ResourceMembership struct {
MembershipID uuid.UUID
Role authz.Role
ResourceType authz.ResourceType
ResourceID uuid.UUID
Expand Down
54 changes: 46 additions & 8 deletions app/controlplane/pkg/biz/apitoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,15 @@ func NewAPITokenUseCase(apiTokenRepo APITokenRepo, jwtConfig *APITokenJWTConfig,
}

type apiTokenOptions struct {
projectID *uuid.UUID
project *Project
showOnlySystemTokens bool
}

type APITokenUseCaseOpt func(*apiTokenOptions)

func APITokenWithProjectID(projectID uuid.UUID) APITokenUseCaseOpt {
func APITokenWithProject(project *Project) APITokenUseCaseOpt {
return func(o *apiTokenOptions) {
o.projectID = &projectID
o.project = project
}
}

Expand Down Expand Up @@ -181,18 +181,38 @@ func (uc *APITokenUseCase) Create(ctx context.Context, name string, description
return nil, fmt.Errorf("finding organization: %w", err)
}

// If a project is provided, we store it in the token
var projectID *uuid.UUID
if options.project != nil {
projectID = ToPtr(options.project.ID)
Copy link
Member

Choose a reason for hiding this comment

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

doesn't &options.project.ID work?

Copy link
Member Author

Choose a reason for hiding this comment

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

it does, I was just using one way of doing it tbh, a little bit pointless it's true

}

// 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, options.projectID)
token, err := uc.apiTokenRepo.Create(ctx, name, description, expiresAt, orgUUID, projectID)
if err != nil {
if IsErrAlreadyExists(err) {
return nil, NewErrAlreadyExistsStr("name already taken")
}
return nil, fmt.Errorf("storing token: %w", err)
}

generationOpts := &apitoken.GenerateJWTOptions{
OrgID: token.OrganizationID,
OrgName: org.Name,
KeyID: token.ID,
KeyName: name,
ExpiresAt: expiresAt,
}

if projectID != nil {
generationOpts.ProjectID = ToPtr(options.project.ID)
generationOpts.ProjectName = ToPtr(options.project.Name)
}

// generate the JWT
token.JWT, err = uc.jwtBuilder.GenerateJWT(token.OrganizationID.String(), org.Name, token.ID.String(), expiresAt)
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)

if err != nil {
return nil, fmt.Errorf("generating jwt: %w", err)
}
Expand Down Expand Up @@ -233,8 +253,16 @@ func (uc *APITokenUseCase) RegenerateJWT(ctx context.Context, tokenID uuid.UUID,
return nil, fmt.Errorf("finding organization: %w", err)
}

generationOpts := &apitoken.GenerateJWTOptions{
OrgID: token.OrganizationID,
OrgName: org.Name,
KeyID: token.ID,
KeyName: token.Name,
ExpiresAt: &expiresAt,
}

// generate the JWT
token.JWT, err = uc.jwtBuilder.GenerateJWT(token.OrganizationID.String(), org.Name, token.ID.String(), &expiresAt)
token.JWT, err = uc.jwtBuilder.GenerateJWT(generationOpts)
if err != nil {
return nil, fmt.Errorf("generating jwt: %w", err)
}
Expand All @@ -258,7 +286,12 @@ func (uc *APITokenUseCase) List(ctx context.Context, orgID string, includeRevoke
return nil, NewErrInvalidUUID(err)
}

return uc.apiTokenRepo.List(ctx, &orgUUID, options.projectID, includeRevoked, options.showOnlySystemTokens)
var projectID *uuid.UUID
if options.project != nil {
projectID = ToPtr(options.project.ID)
}

return uc.apiTokenRepo.List(ctx, &orgUUID, projectID, includeRevoked, options.showOnlySystemTokens)
}

func (uc *APITokenUseCase) Revoke(ctx context.Context, orgID, id string) error {
Expand Down Expand Up @@ -308,7 +341,12 @@ func (uc *APITokenUseCase) FindByNameInOrg(ctx context.Context, orgID, name stri
return nil, NewErrInvalidUUID(err)
}

t, err := uc.apiTokenRepo.FindByNameInOrg(ctx, orgUUID, name, options.projectID)
var projectID *uuid.UUID
if options.project != nil {
projectID = ToPtr(options.project.ID)
}

t, err := uc.apiTokenRepo.FindByNameInOrg(ctx, orgUUID, name, projectID)
if err != nil {
return nil, fmt.Errorf("finding token: %w", err)
}
Expand Down
Loading
Loading