diff --git a/components/ambient-api-server/pkg/rbac/hierarchy.go b/components/ambient-api-server/pkg/rbac/hierarchy.go index c884c0199..95f85eb2d 100644 --- a/components/ambient-api-server/pkg/rbac/hierarchy.go +++ b/components/ambient-api-server/pkg/rbac/hierarchy.go @@ -6,12 +6,13 @@ var RoleLevel = map[string]int{ RoleProjectOwner: 1, RoleCredentialOwner: 1, - RolePlatformViewer: 2, - RoleProjectEditor: 2, - RoleAgentOperator: 2, - "agent:editor": 2, - RoleCredentialReader: 2, - "credential:viewer": 2, + RolePlatformViewer: 2, + RoleProjectEditor: 2, + RoleAgentOperator: 2, + "agent:editor": 2, + RoleCredentialReader: 2, + RoleCredentialTokenReader: 2, + "credential:viewer": 2, RoleProjectViewer: 3, RoleAgentObserver: 3, diff --git a/components/ambient-api-server/plugins/roleBindings/handler.go b/components/ambient-api-server/plugins/roleBindings/handler.go index b3da575c5..c5cb8b59f 100644 --- a/components/ambient-api-server/plugins/roleBindings/handler.go +++ b/components/ambient-api-server/plugins/roleBindings/handler.go @@ -46,17 +46,20 @@ func (h roleBindingHandler) Create(w http.ResponseWriter, r *http.Request) { if h.sessionFactory == nil { return nil, errors.Forbidden("authorization not available") } + + validScopes := map[string]bool{"global": true, "project": true, "agent": true, "session": true, "credential": true} + if !validScopes[roleBinding.Scope] { + return nil, errors.BadRequest("invalid scope") + } + { g := (*h.sessionFactory).New(ctx) - // a) Look up target role name and reject internal roles + // a) Look up target role name var targetRoleName string if err := g.Table("roles").Select("name").Where("id = ? AND deleted_at IS NULL", roleBinding.RoleId).Scan(&targetRoleName).Error; err != nil || targetRoleName == "" { return nil, errors.Forbidden("target role not found") } - if pkgrbac.InternalRoles[targetRoleName] { - return nil, errors.Forbidden("cannot assign internal role") - } // b) Level hierarchy check — scoped to the target resource username := auth.GetUsernameFromContext(ctx) @@ -76,9 +79,12 @@ func (h roleBindingHandler) Create(w http.ResponseWriter, r *http.Request) { scanErr = baseQuery(g).Scan(&callerRoleNames).Error } if scanErr != nil { - return nil, errors.GeneralError("failed to query caller roles: %v", scanErr) + return nil, errors.GeneralError("authorization check failed") } callerLevel := pkgrbac.HighestLevel(callerRoleNames) + if pkgrbac.InternalRoles[targetRoleName] && callerLevel != 0 { + return nil, errors.Forbidden("cannot assign internal role") + } if !pkgrbac.CanGrant(callerLevel, targetRoleName) { return nil, errors.Forbidden("insufficient privileges to grant this role") } @@ -95,37 +101,76 @@ func (h roleBindingHandler) Create(w http.ResponseWriter, r *http.Request) { Where("user_id = ? AND (project_id = ? OR scope = 'global') AND deleted_at IS NULL", username, *roleBinding.ProjectId.Get()). Count(&projCount).Error; dbErr != nil { - return nil, errors.GeneralError("failed to check project access: %v", dbErr) + return nil, errors.GeneralError("authorization check failed") } if projCount == 0 { return nil, errors.Forbidden("caller has no access to this project") } } - // c) Credential scope: caller must be credential:owner AND project:owner + // c) Credential scope authorization if roleBinding.Scope == "credential" && roleBinding.CredentialId.IsSet() { + hasProjectID := roleBinding.ProjectId.IsSet() && roleBinding.ProjectId.Get() != nil + hasAgentID := roleBinding.AgentId.IsSet() && roleBinding.AgentId.Get() != nil + + if hasProjectID && *roleBinding.ProjectId.Get() == "" { + return nil, errors.BadRequest("project_id must not be empty") + } + if hasAgentID && *roleBinding.AgentId.Get() == "" { + return nil, errors.BadRequest("agent_id must not be empty") + } + + // c1) agent_id requires project_id + if hasAgentID && !hasProjectID { + return nil, errors.BadRequest("agent-scoped credential bindings require a project_id") + } + + // c2) Validate agent belongs to the specified project + if hasAgentID && hasProjectID { + var agentProjectID string + if dbErr := g.Table("agents").Select("project_id"). + Where("id = ? AND deleted_at IS NULL", *roleBinding.AgentId.Get()). + Scan(&agentProjectID).Error; dbErr != nil || agentProjectID == "" { + return nil, errors.BadRequest("agent not found") + } + if agentProjectID != *roleBinding.ProjectId.Get() { + return nil, errors.BadRequest("agent does not belong to the specified project") + } + } + + // c3) Caller must be credential:owner var credOwnerCount int64 if dbErr := g.Table("role_bindings"). Joins("JOIN roles ON roles.id = role_bindings.role_id"). Where("role_bindings.user_id = ? AND roles.name = ? AND role_bindings.credential_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL", username, pkgrbac.RoleCredentialOwner, *roleBinding.CredentialId.Get()). Count(&credOwnerCount).Error; dbErr != nil { - return nil, errors.GeneralError("failed to check credential ownership: %v", dbErr) + return nil, errors.GeneralError("authorization check failed") } if credOwnerCount == 0 { return nil, errors.Forbidden("caller must be credential owner to grant credential-scoped bindings") } - if roleBinding.ProjectId.IsSet() { - var projOwnerCount int64 + + // c4) Project-level or agent-level: caller needs project:editor or higher + if hasProjectID && callerLevel != 0 { + var projEditorCount int64 if dbErr := g.Table("role_bindings"). Joins("JOIN roles ON roles.id = role_bindings.role_id"). - Where("role_bindings.user_id = ? AND roles.name = ? AND role_bindings.project_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL", - username, pkgrbac.RoleProjectOwner, *roleBinding.ProjectId.Get()). - Count(&projOwnerCount).Error; dbErr != nil { - return nil, errors.GeneralError("failed to check project ownership: %v", dbErr) + Where("role_bindings.user_id = ? AND role_bindings.project_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL", + username, *roleBinding.ProjectId.Get()). + Where("roles.name IN ?", []string{pkgrbac.RoleProjectOwner, pkgrbac.RoleProjectEditor}). + Count(&projEditorCount).Error; dbErr != nil { + return nil, errors.GeneralError("authorization check failed") } - if projOwnerCount == 0 { - return nil, errors.Forbidden("caller must be project owner to bind credentials to a project") + if projEditorCount == 0 { + return nil, errors.Forbidden("caller must be project editor or higher to bind credentials to a project") + } + } + + // c5) Global credential binding: requires platform:admin + if !hasProjectID && !hasAgentID { + if callerLevel != 0 { + return nil, errors.Forbidden("only platform admins can create global credential bindings") } } } @@ -173,7 +218,7 @@ func (h roleBindingHandler) Patch(w http.ResponseWriter, r *http.Request) { Joins("JOIN roles r ON r.id = rb.role_id"). Where("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL", username). Scan(&callerRoleNames).Error; dbErr != nil { - return nil, errors.GeneralError("failed to query caller roles: %v", dbErr) + return nil, errors.GeneralError("authorization check failed") } callerLevel := pkgrbac.HighestLevel(callerRoleNames) @@ -198,9 +243,12 @@ func (h roleBindingHandler) Patch(w http.ResponseWriter, r *http.Request) { } // Prevent changing user_id (ownership transfer). - if patch.UserId.IsSet() && (found.UserId == nil || *patch.UserId.Get() != *found.UserId) { - if callerLevel != 0 { - return nil, errors.Forbidden("Forbidden") + if patch.UserId.IsSet() { + patchVal := patch.UserId.Get() + if found.UserId == nil || patchVal == nil || *patchVal != *found.UserId { + if callerLevel != 0 { + return nil, errors.Forbidden("Forbidden") + } } } @@ -209,17 +257,29 @@ func (h roleBindingHandler) Patch(w http.ResponseWriter, r *http.Request) { if patch.Scope != nil && *patch.Scope != found.Scope { return nil, errors.Forbidden("Forbidden") } - if patch.ProjectId.IsSet() && (found.ProjectId == nil || *patch.ProjectId.Get() != *found.ProjectId) { - return nil, errors.Forbidden("Forbidden") + if patch.ProjectId.IsSet() { + patchVal := patch.ProjectId.Get() + if found.ProjectId == nil || patchVal == nil || *patchVal != *found.ProjectId { + return nil, errors.Forbidden("Forbidden") + } } - if patch.AgentId.IsSet() && (found.AgentId == nil || *patch.AgentId.Get() != *found.AgentId) { - return nil, errors.Forbidden("Forbidden") + if patch.AgentId.IsSet() { + patchVal := patch.AgentId.Get() + if found.AgentId == nil || patchVal == nil || *patchVal != *found.AgentId { + return nil, errors.Forbidden("Forbidden") + } } - if patch.SessionId.IsSet() && (found.SessionId == nil || *patch.SessionId.Get() != *found.SessionId) { - return nil, errors.Forbidden("Forbidden") + if patch.SessionId.IsSet() { + patchVal := patch.SessionId.Get() + if found.SessionId == nil || patchVal == nil || *patchVal != *found.SessionId { + return nil, errors.Forbidden("Forbidden") + } } - if patch.CredentialId.IsSet() && (found.CredentialId == nil || *patch.CredentialId.Get() != *found.CredentialId) { - return nil, errors.Forbidden("Forbidden") + if patch.CredentialId.IsSet() { + patchVal := patch.CredentialId.Get() + if found.CredentialId == nil || patchVal == nil || *patchVal != *found.CredentialId { + return nil, errors.Forbidden("Forbidden") + } } } } @@ -364,7 +424,7 @@ func (h roleBindingHandler) Delete(w http.ResponseWriter, r *http.Request) { var roleName string g := (*h.sessionFactory).New(ctx) if dbErr := g.Table("roles").Select("name").Where("id = ? AND deleted_at IS NULL", binding.RoleId).Scan(&roleName).Error; dbErr != nil { - return nil, errors.GeneralError("failed to look up role: %v", dbErr) + return nil, errors.GeneralError("authorization check failed") } if roleName == pkgrbac.RoleProjectOwner && binding.ProjectId != nil { @@ -373,7 +433,7 @@ func (h roleBindingHandler) Delete(w http.ResponseWriter, r *http.Request) { Where("role_id = ? AND project_id = ? AND deleted_at IS NULL", binding.RoleId, *binding.ProjectId). Count(&count).Error; dbErr != nil { - return nil, errors.GeneralError("failed to count owner bindings: %v", dbErr) + return nil, errors.GeneralError("authorization check failed") } if count <= 1 { return nil, errors.New(errors.ErrorConflict, "cannot delete the last owner binding") @@ -385,31 +445,67 @@ func (h roleBindingHandler) Delete(w http.ResponseWriter, r *http.Request) { Where("role_id = ? AND credential_id = ? AND deleted_at IS NULL", binding.RoleId, *binding.CredentialId). Count(&count).Error; dbErr != nil { - return nil, errors.GeneralError("failed to count owner bindings: %v", dbErr) + return nil, errors.GeneralError("authorization check failed") } if count <= 1 { return nil, errors.New(errors.ErrorConflict, "cannot delete the last owner binding") } } - // --- Hierarchy check: caller must outrank the binding's role --- + // --- Authorization check --- username := auth.GetUsernameFromContext(ctx) - var callerRoleNames []string - baseQuery := g.Table("role_bindings rb"). - Select("r.name"). - Joins("JOIN roles r ON r.id = rb.role_id"). - Where("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL", username) - if binding.Scope == "project" && binding.ProjectId != nil { - baseQuery = baseQuery.Where("rb.project_id = ? OR rb.scope = 'global'", *binding.ProjectId) - } else if binding.Scope == "credential" && binding.CredentialId != nil { - baseQuery = baseQuery.Where("rb.credential_id = ? OR rb.scope = 'global'", *binding.CredentialId) - } - if dbErr := baseQuery.Scan(&callerRoleNames).Error; dbErr != nil { - return nil, errors.GeneralError("failed to query caller roles: %v", dbErr) - } - callerLevel := pkgrbac.HighestLevel(callerRoleNames) - if !pkgrbac.CanGrant(callerLevel, roleName) { - return nil, errors.Forbidden("insufficient privileges to delete this binding") + + if binding.Scope == "credential" { + // Asymmetric unbind: project:editor+ can remove credential bindings + // from their project without needing credential:owner. + // platform:admin can always unbind. + var callerAllRoles []string + if dbErr := g.Table("role_bindings rb"). + Select("r.name"). + Joins("JOIN roles r ON r.id = rb.role_id"). + Where("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL", username). + Scan(&callerAllRoles).Error; dbErr != nil { + return nil, errors.GeneralError("authorization check failed") + } + callerLevel := pkgrbac.HighestLevel(callerAllRoles) + + if callerLevel == 0 { + // platform:admin can always unbind + } else if binding.ProjectId != nil { + var projEditorCount int64 + if dbErr := g.Table("role_bindings"). + Joins("JOIN roles ON roles.id = role_bindings.role_id"). + Where("role_bindings.user_id = ? AND role_bindings.project_id = ? AND role_bindings.deleted_at IS NULL AND roles.deleted_at IS NULL", + username, *binding.ProjectId). + Where("roles.name IN ?", []string{pkgrbac.RoleProjectOwner, pkgrbac.RoleProjectEditor}). + Count(&projEditorCount).Error; dbErr != nil { + return nil, errors.GeneralError("authorization check failed") + } + if projEditorCount == 0 { + return nil, errors.Forbidden("insufficient privileges to delete this binding") + } + } else { + // Global credential binding: requires platform:admin (already checked above) + return nil, errors.Forbidden("insufficient privileges to delete this binding") + } + } else { + // Non-credential scopes: caller must outrank the binding's role + // AND be at least project:owner (level 1) + var callerRoleNames []string + baseQuery := g.Table("role_bindings rb"). + Select("r.name"). + Joins("JOIN roles r ON r.id = rb.role_id"). + Where("rb.user_id = ? AND r.deleted_at IS NULL AND rb.deleted_at IS NULL", username) + if binding.Scope == "project" && binding.ProjectId != nil { + baseQuery = baseQuery.Where("rb.project_id = ? OR rb.scope = 'global'", *binding.ProjectId) + } + if dbErr := baseQuery.Scan(&callerRoleNames).Error; dbErr != nil { + return nil, errors.GeneralError("authorization check failed") + } + callerLevel := pkgrbac.HighestLevel(callerRoleNames) + if callerLevel > 1 || !pkgrbac.CanGrant(callerLevel, roleName) { + return nil, errors.Forbidden("insufficient privileges to delete this binding") + } } } diff --git a/components/ambient-api-server/plugins/roles/migration.go b/components/ambient-api-server/plugins/roles/migration.go index 6fdfa4ab4..9c3a0b932 100644 --- a/components/ambient-api-server/plugins/roles/migration.go +++ b/components/ambient-api-server/plugins/roles/migration.go @@ -111,3 +111,33 @@ func seedBuiltInRoles(tx *gorm.DB) error { } return nil } + +func editorCredentialUnbindMigration() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202606091900", + Migrate: func(tx *gorm.DB) error { + var perms string + if err := tx.Raw(`SELECT permissions FROM roles WHERE name = 'project:editor' AND deleted_at IS NULL`).Scan(&perms).Error; err != nil { + return err + } + var permList []string + if err := json.Unmarshal([]byte(perms), &permList); err != nil { + return err + } + for _, p := range permList { + if p == "role_binding:delete" { + return nil + } + } + permList = append(permList, "role_binding:delete") + updated, err := json.Marshal(permList) + if err != nil { + return err + } + return tx.Exec(`UPDATE roles SET permissions = ?, updated_at = NOW() WHERE name = 'project:editor' AND deleted_at IS NULL`, string(updated)).Error + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, + } +} diff --git a/components/ambient-api-server/plugins/roles/plugin.go b/components/ambient-api-server/plugins/roles/plugin.go index 79003725a..58bb17af1 100644 --- a/components/ambient-api-server/plugins/roles/plugin.go +++ b/components/ambient-api-server/plugins/roles/plugin.go @@ -81,4 +81,5 @@ func init() { presenters.RegisterKind(&Role{}, "Role") db.RegisterMigration(migration()) + db.RegisterMigration(editorCredentialUnbindMigration()) } diff --git a/components/ambient-api-server/test/e2e/rbac_e2e_test.sh b/components/ambient-api-server/test/e2e/rbac_e2e_test.sh index f0e7dedc3..be2919efd 100755 --- a/components/ambient-api-server/test/e2e/rbac_e2e_test.sh +++ b/components/ambient-api-server/test/e2e/rbac_e2e_test.sh @@ -1465,6 +1465,125 @@ else skip "F5: DB pod not found" fi +# ============================================================ +echo "" +echo -e "${BOLD}Phase 26: Credential Binding Enforcement (spec: credential-binding-enforcement)${NC}" + +# This phase tests the hierarchical credential binding authorization rules: +# - project:editor can bind credentials (not just owner) +# - project:viewer cannot bind credentials +# - agent_id without project_id is rejected (400) +# - agent not belonging to project is rejected (400) +# - global credential bindings require platform:admin +# - project:editor can unbind without credential:owner (asymmetric) +# - non-admin users cannot create internal role bindings (credential:token-reader) + +# Setup: give User B project:editor on proj-alpha for this phase +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_PROJECT_EDITOR}\",\"scope\":\"project\",\"user_id\":\"rbac-user-b\",\"project_id\":\"rbac-proj-alpha\"}" +CB_EDITOR_BIND_ID=$(echo "$HTTP_BODY" | jq -r '.id // empty') + +# --- Scenario: project:editor can bind credential to project --- +# User A owns cred-a and proj-alpha. User B has project:editor on proj-alpha. +# User A (credential:owner + project:editor-via-ownership) binds cred-a to proj-alpha. +# But the spec says project:editor is enough — test User A who has project:owner (level 1 ≤ 2, passes). +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-b\",\"credential_id\":\"${CRED_A_ID}\",\"project_id\":\"rbac-proj-alpha\"}" +assert_status "201" "$HTTP_STATUS" "CB: credential owner + project owner can bind credential to project" +CB_BIND_1=$(echo "$HTTP_BODY" | jq -r '.id // empty') + +# Clean up binding for next test +[[ -n "$CB_BIND_1" ]] && api DELETE "/role_bindings/${CB_BIND_1}" "$TOKEN_A" + +# --- Scenario: project:viewer cannot bind credentials --- +# Give User C project:viewer on proj-alpha +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_PROJECT_VIEWER}\",\"scope\":\"project\",\"user_id\":\"rbac-user-c\",\"project_id\":\"rbac-proj-alpha\"}" +CB_VIEWER_BIND_ID=$(echo "$HTTP_BODY" | jq -r '.id // empty') + +# Give User C credential:owner on cred-c (they created it in Phase 12) +# User C tries to bind their credential to proj-alpha where they're only viewer +if [[ -n "$CRED_C_ID" ]]; then + api POST "/role_bindings" "$TOKEN_C" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-c\",\"credential_id\":\"${CRED_C_ID}\",\"project_id\":\"rbac-proj-alpha\"}" + assert_status "403" "$HTTP_STATUS" "CB: project:viewer cannot bind credential to project" +else + skip "CB: project:viewer bind test (no cred_c_id)" +fi + +# Clean up viewer binding +[[ -n "$CB_VIEWER_BIND_ID" ]] && api DELETE "/role_bindings/${CB_VIEWER_BIND_ID}" "$TOKEN_A" + +# --- Scenario: agent_id without project_id is rejected (400) --- +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\",\"agent_id\":\"${AGENT_A_ID}\"}" +assert_status "400" "$HTTP_STATUS" "CB: agent_id without project_id returns 400" + +# --- Scenario: agent not belonging to project is rejected (400) --- +# AGENT_B_ID belongs to proj-beta. Binding it to proj-alpha should fail. +if [[ -n "$AGENT_B_ID" ]]; then + api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\",\"project_id\":\"rbac-proj-alpha\",\"agent_id\":\"${AGENT_B_ID}\"}" + assert_status "400" "$HTTP_STATUS" "CB: agent not in project returns 400" +else + skip "CB: agent-project mismatch test (no agent_b_id)" +fi + +# --- Scenario: valid agent-level binding accepted --- +if [[ -n "$AGENT_A_ID" ]]; then + api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\",\"project_id\":\"rbac-proj-alpha\",\"agent_id\":\"${AGENT_A_ID}\"}" + assert_status "201" "$HTTP_STATUS" "CB: agent-level binding with correct project accepted" + CB_AGENT_BIND=$(echo "$HTTP_BODY" | jq -r '.id // empty') + [[ -n "$CB_AGENT_BIND" ]] && api DELETE "/role_bindings/${CB_AGENT_BIND}" "$TOKEN_A" +else + skip "CB: agent-level binding test (no agent_a_id)" +fi + +# --- Scenario: global credential binding requires platform:admin --- +# User A has credential:owner on cred-a but is NOT platform:admin +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\"}" +assert_status "403" "$HTTP_STATUS" "CB: non-admin cannot create global credential binding" + +# --- Scenario: non-credential-owner cannot bind --- +# User B owns cred-b but NOT cred-a. User B is project:editor on proj-alpha. +api POST "/role_bindings" "$TOKEN_B" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-b\",\"credential_id\":\"${CRED_A_ID}\",\"project_id\":\"rbac-proj-alpha\"}" +assert_status "403" "$HTTP_STATUS" "CB: non-credential-owner cannot bind (editor on project but not cred owner)" + +# --- Scenario: asymmetric unbind — project:editor can remove binding without credential:owner --- +# First, User A (cred owner + proj owner) creates a binding +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\",\"project_id\":\"rbac-proj-alpha\"}" +CB_UNBIND_ID=$(echo "$HTTP_BODY" | jq -r '.id // empty') + +if [[ -n "$CB_UNBIND_ID" ]]; then + # User B (project:editor, NOT credential:owner) deletes the binding + api DELETE "/role_bindings/${CB_UNBIND_ID}" "$TOKEN_B" + assert_status "204" "$HTTP_STATUS" "CB: project:editor can unbind credential without credential:owner" +else + fail "CB: asymmetric unbind setup" "could not create binding to test unbind" +fi + +# --- Scenario: project:viewer cannot unbind --- +# Re-create binding, give User C viewer, test they cannot unbind +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CREDENTIAL_VIEWER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\",\"project_id\":\"rbac-proj-alpha\"}" +CB_UNBIND_ID2=$(echo "$HTTP_BODY" | jq -r '.id // empty') +api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_PROJECT_VIEWER}\",\"scope\":\"project\",\"user_id\":\"rbac-user-c\",\"project_id\":\"rbac-proj-alpha\"}" +CB_VIEWER_BIND_2=$(echo "$HTTP_BODY" | jq -r '.id // empty') + +if [[ -n "$CB_UNBIND_ID2" ]]; then + api DELETE "/role_bindings/${CB_UNBIND_ID2}" "$TOKEN_C" + assert_status "403" "$HTTP_STATUS" "CB: project:viewer cannot unbind credential" + # Clean up + api DELETE "/role_bindings/${CB_UNBIND_ID2}" "$TOKEN_A" +else + fail "CB: viewer unbind test setup" "could not create binding" +fi +[[ -n "$CB_VIEWER_BIND_2" ]] && api DELETE "/role_bindings/${CB_VIEWER_BIND_2}" "$TOKEN_A" + +# --- Scenario: non-admin user cannot create credential:token-reader binding (internal role) --- +if [[ -n "$ROLE_CRED_TOKEN_READER" ]]; then + api POST "/role_bindings" "$TOKEN_A" "{\"role_id\":\"${ROLE_CRED_TOKEN_READER}\",\"scope\":\"credential\",\"user_id\":\"rbac-user-a\",\"credential_id\":\"${CRED_A_ID}\"}" + assert_status "403" "$HTTP_STATUS" "CB: non-admin cannot create credential:token-reader binding" +else + skip "CB: token-reader internal role test (role not found)" +fi + +# Cleanup phase bindings +[[ -n "$CB_EDITOR_BIND_ID" ]] && api DELETE "/role_bindings/${CB_EDITOR_BIND_ID}" "$TOKEN_A" + # ============================================================ # Cleanup is handled by the EXIT trap (clean_db + Keycloak user deletion) # ============================================================ diff --git a/components/ambient-api-server/test/integration/credential_binding_test.go b/components/ambient-api-server/test/integration/credential_binding_test.go new file mode 100644 index 000000000..a1da041dd --- /dev/null +++ b/components/ambient-api-server/test/integration/credential_binding_test.go @@ -0,0 +1,504 @@ +package integration + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + + . "github.com/onsi/gomega" + "gopkg.in/resty.v1" + + "github.com/ambient-code/platform/components/ambient-api-server/pkg/api/openapi" + "github.com/ambient-code/platform/components/ambient-api-server/test" + "github.com/openshift-online/rh-trex-ai/pkg/api" + "github.com/openshift-online/rh-trex-ai/pkg/environments" +) + +// setupCredentialBindingTest seeds roles, creates a credential owned by ownerUsername, +// and returns (credentialID, credentialRoleID for "credential:owner" scope bindings). +// The caller is responsible for calling h.DBFactory.ResetDB() first. +func setupCredentialBindingTest(t *testing.T, h *test.Helper, ownerUsername string) (credID string, credOwnerRoleID string) { + t.Helper() + ensureBuiltInRoles(t) + g := environments.Environment().Database.SessionFactory.New(context.Background()) + + // Create a credential directly in the DB + credID = api.NewID() + err := g.Exec( + `INSERT INTO credentials (id, name, provider, token, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW())`, + credID, "test-cred", "github", "encrypted-token", + ).Error + Expect(err).NotTo(HaveOccurred()) + + // Look up credential:owner role ID + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:owner' AND deleted_at IS NULL`).Scan(&credOwnerRoleID).Error + Expect(err).NotTo(HaveOccurred()) + Expect(credOwnerRoleID).NotTo(BeEmpty()) + + // Create credential:owner binding for ownerUsername + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, credential_id, created_at, updated_at) + VALUES (?, ?, 'credential', ?, ?, NOW(), NOW())`, + api.NewID(), credOwnerRoleID, ownerUsername, credID, + ).Error + Expect(err).NotTo(HaveOccurred()) + + return credID, credOwnerRoleID +} + +// setupProjectWithRole creates a project and gives username the specified role on it. +// Returns the project ID and the role ID used. +func setupProjectWithRole(t *testing.T, username, roleName string) (projectID string, roleID string) { + t.Helper() + g := environments.Environment().Database.SessionFactory.New(context.Background()) + + projectID = api.NewID() + err := g.Exec( + `INSERT INTO projects (id, name, created_at, updated_at) + VALUES (?, ?, NOW(), NOW())`, + projectID, fmt.Sprintf("proj-%s", projectID[:8]), + ).Error + Expect(err).NotTo(HaveOccurred()) + + err = g.Raw(`SELECT id FROM roles WHERE name = ? AND deleted_at IS NULL`, roleName).Scan(&roleID).Error + Expect(err).NotTo(HaveOccurred()) + Expect(roleID).NotTo(BeEmpty(), "role %s not found", roleName) + + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, project_id, created_at, updated_at) + VALUES (?, ?, 'project', ?, ?, NOW(), NOW())`, + api.NewID(), roleID, username, projectID, + ).Error + Expect(err).NotTo(HaveOccurred()) + + return projectID, roleID +} + +// createCredentialBinding creates a credential-scope role binding via the REST API using resty. +func createCredentialBinding(h *test.Helper, ctx context.Context, body map[string]interface{}) (*resty.Response, error) { + jwtToken := ctx.Value(openapi.ContextAccessToken) + return resty.R(). + SetHeader("Content-Type", "application/json"). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). + SetBody(body). + Post(h.RestURL("/role_bindings")) +} + +// deleteBinding deletes a role binding via the REST API using resty. +func deleteBinding(h *test.Helper, ctx context.Context, bindingID string) (*resty.Response, error) { + jwtToken := ctx.Value(openapi.ContextAccessToken) + return resty.R(). + SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). + Delete(h.RestURL(fmt.Sprintf("/role_bindings/%s", bindingID))) +} + +// --- Credential Binding Create Tests --- + +func TestCredentialBinding_ProjectEditorCanBind(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + username := "editor-user" + account := h.NewAccount(username, "Editor User", "editor@test.com") + ctx := h.NewAuthenticatedContext(account) + + credID, _ := setupCredentialBindingTest(t, h, username) + projectID, _ := setupProjectWithRole(t, username, "project:editor") + + // Look up a role to bind (credential:reader for example) + g := environments.Environment().Database.SessionFactory.New(context.Background()) + var credReaderRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + "project_id": projectID, + "user_id": "some-other-user", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusCreated), + "project:editor should be able to bind credentials; got %d: %s", resp.StatusCode(), resp.String()) +} + +func TestCredentialBinding_ProjectViewerCannotBind(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + username := "viewer-user" + account := h.NewAccount(username, "Viewer User", "viewer@test.com") + ctx := h.NewAuthenticatedContext(account) + + credID, _ := setupCredentialBindingTest(t, h, username) + projectID, _ := setupProjectWithRole(t, username, "project:viewer") + + g := environments.Environment().Database.SessionFactory.New(context.Background()) + var credReaderRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + "project_id": projectID, + "user_id": "some-other-user", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusForbidden), + "project:viewer should NOT be able to bind credentials; got %d: %s", resp.StatusCode(), resp.String()) +} + +func TestCredentialBinding_AgentWithoutProjectRejected(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + username := "admin-user" + account := h.NewAccount(username, "Admin User", "admin@test.com") + ctx := h.NewAuthenticatedContext(account) + + credID, _ := setupCredentialBindingTest(t, h, username) + + // Give user platform:admin so they'd pass all other checks + g := environments.Environment().Database.SessionFactory.New(context.Background()) + var adminRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'platform:admin' AND deleted_at IS NULL`).Scan(&adminRoleID).Error + Expect(err).NotTo(HaveOccurred()) + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, created_at, updated_at) + VALUES (?, ?, 'global', ?, NOW(), NOW())`, + api.NewID(), adminRoleID, username, + ).Error + Expect(err).NotTo(HaveOccurred()) + + var credReaderRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + // agent_id without project_id should be rejected + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + "agent_id": "some-agent-id", + // project_id intentionally omitted + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest), + "agent_id without project_id should return 400; got %d: %s", resp.StatusCode(), resp.String()) +} + +func TestCredentialBinding_AgentNotInProjectRejected(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + username := "owner-user" + account := h.NewAccount(username, "Owner User", "owner@test.com") + ctx := h.NewAuthenticatedContext(account) + + credID, _ := setupCredentialBindingTest(t, h, username) + projectID, _ := setupProjectWithRole(t, username, "project:owner") + + // Create an agent in a DIFFERENT project + g := environments.Environment().Database.SessionFactory.New(context.Background()) + otherProjectID := api.NewID() + err := g.Exec( + `INSERT INTO projects (id, name, created_at, updated_at) + VALUES (?, ?, NOW(), NOW())`, + otherProjectID, "other-project", + ).Error + Expect(err).NotTo(HaveOccurred()) + + agentID := api.NewID() + err = g.Exec( + `INSERT INTO agents (id, project_id, name, created_at, updated_at) + VALUES (?, ?, ?, NOW(), NOW())`, + agentID, otherProjectID, "wrong-project-agent", + ).Error + Expect(err).NotTo(HaveOccurred()) + + var credReaderRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + // Binding agent from otherProject to projectID should fail + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + "project_id": projectID, + "agent_id": agentID, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest), + "agent not in project should return 400; got %d: %s", resp.StatusCode(), resp.String()) +} + +func TestCredentialBinding_GlobalRequiresAdmin(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + username := "nonadmin-user" + account := h.NewAccount(username, "Non-Admin User", "nonadmin@test.com") + ctx := h.NewAuthenticatedContext(account) + + credID, _ := setupCredentialBindingTest(t, h, username) + + g := environments.Environment().Database.SessionFactory.New(context.Background()) + var credReaderRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + // Global binding (no project_id, no agent_id) without platform:admin + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + // project_id and agent_id intentionally omitted → global binding + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusForbidden), + "non-admin should not create global credential binding; got %d: %s", resp.StatusCode(), resp.String()) +} + +func TestCredentialBinding_NonCredentialOwnerCannotBind(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + ownerUsername := "cred-owner" + nonOwnerUsername := "non-owner" + ensureBuiltInRoles(t) + + // Create credential owned by ownerUsername + g := environments.Environment().Database.SessionFactory.New(context.Background()) + credID := api.NewID() + err := g.Exec( + `INSERT INTO credentials (id, name, provider, token, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW())`, + credID, "someone-elses-cred", "github", "encrypted-token", + ).Error + Expect(err).NotTo(HaveOccurred()) + + var credOwnerRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:owner' AND deleted_at IS NULL`).Scan(&credOwnerRoleID).Error + Expect(err).NotTo(HaveOccurred()) + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, credential_id, created_at, updated_at) + VALUES (?, ?, 'credential', ?, ?, NOW(), NOW())`, + api.NewID(), credOwnerRoleID, ownerUsername, credID, + ).Error + Expect(err).NotTo(HaveOccurred()) + + // Give non-owner project:owner on a project + projectID, _ := setupProjectWithRole(t, nonOwnerUsername, "project:owner") + + account := h.NewAccount(nonOwnerUsername, "Non Owner", "nonowner@test.com") + ctx := h.NewAuthenticatedContext(account) + + var credReaderRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + "project_id": projectID, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusForbidden), + "non-credential-owner should not bind; got %d: %s", resp.StatusCode(), resp.String()) +} + +// --- Credential Binding Delete Tests --- + +func TestCredentialBinding_ProjectEditorCanUnbindWithoutCredentialOwner(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + credOwnerUsername := "cred-owner" + editorUsername := "project-editor" + + credID, _ := setupCredentialBindingTest(t, h, credOwnerUsername) + projectID, _ := setupProjectWithRole(t, editorUsername, "project:editor") + + // Create a credential-scope binding on the project (simulating cred-owner bound it) + g := environments.Environment().Database.SessionFactory.New(context.Background()) + var credReaderRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + bindingID := api.NewID() + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, credential_id, project_id, created_at, updated_at) + VALUES (?, ?, 'credential', ?, ?, ?, NOW(), NOW())`, + bindingID, credReaderRoleID, "some-user", credID, projectID, + ).Error + Expect(err).NotTo(HaveOccurred()) + + // Editor (not credential owner) should be able to delete binding from their project + account := h.NewAccount(editorUsername, "Editor User", "editor@test.com") + ctx := h.NewAuthenticatedContext(account) + + resp, err := deleteBinding(h, ctx, bindingID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusNoContent), + "project:editor should unbind credentials without credential:owner; got %d: %s", resp.StatusCode(), resp.String()) +} + +func TestCredentialBinding_ProjectViewerCannotUnbind(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + credOwnerUsername := "cred-owner" + viewerUsername := "project-viewer" + + credID, _ := setupCredentialBindingTest(t, h, credOwnerUsername) + projectID, _ := setupProjectWithRole(t, viewerUsername, "project:viewer") + + g := environments.Environment().Database.SessionFactory.New(context.Background()) + var credReaderRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + bindingID := api.NewID() + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, credential_id, project_id, created_at, updated_at) + VALUES (?, ?, 'credential', ?, ?, ?, NOW(), NOW())`, + bindingID, credReaderRoleID, "some-user", credID, projectID, + ).Error + Expect(err).NotTo(HaveOccurred()) + + account := h.NewAccount(viewerUsername, "Viewer User", "viewer@test.com") + ctx := h.NewAuthenticatedContext(account) + + resp, err := deleteBinding(h, ctx, bindingID) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusForbidden), + "project:viewer should NOT unbind credentials; got %d: %s", resp.StatusCode(), resp.String()) +} + +// --- Service Account / Internal Role Tests --- + +func TestCredentialBinding_ServiceAccountCanCreateInternalRole(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + ensureBuiltInRoles(t) + + // Simulate a service account by giving the user platform:admin. + // In the real system, middleware.IsServiceCaller checks a context flag set + // during auth. In integration tests, the mock auth middleware may or may not + // set this flag. If this test fails with "cannot assign internal role", that's + // the expected RED state — the implementation must allow platform:admin callers + // (service accounts) to create internal role bindings. + username := "service-account-cp" + account := h.NewAccount(username, "Control Plane SA", "cp@svc.local") + ctx := h.NewAuthenticatedContext(account) + + g := environments.Environment().Database.SessionFactory.New(context.Background()) + + // Give user platform:admin + var adminRoleID string + err := g.Raw(`SELECT id FROM roles WHERE name = 'platform:admin' AND deleted_at IS NULL`).Scan(&adminRoleID).Error + Expect(err).NotTo(HaveOccurred()) + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, created_at, updated_at) + VALUES (?, ?, 'global', ?, NOW(), NOW())`, + api.NewID(), adminRoleID, username, + ).Error + Expect(err).NotTo(HaveOccurred()) + + // Create a credential + credID := api.NewID() + err = g.Exec( + `INSERT INTO credentials (id, name, provider, token, created_at, updated_at) + VALUES (?, ?, ?, ?, NOW(), NOW())`, + credID, "sa-test-cred", "github", "encrypted", + ).Error + Expect(err).NotTo(HaveOccurred()) + + // credential:owner binding for the SA + var credOwnerRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:owner' AND deleted_at IS NULL`).Scan(&credOwnerRoleID).Error + Expect(err).NotTo(HaveOccurred()) + err = g.Exec( + `INSERT INTO role_bindings (id, role_id, scope, user_id, credential_id, created_at, updated_at) + VALUES (?, ?, 'credential', ?, ?, NOW(), NOW())`, + api.NewID(), credOwnerRoleID, username, credID, + ).Error + Expect(err).NotTo(HaveOccurred()) + + // Try to create credential:token-reader binding (internal role) + var tokenReaderRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:token-reader' AND deleted_at IS NULL`).Scan(&tokenReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": tokenReaderRoleID, + "scope": "credential", + "credential_id": credID, + "user_id": username, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusCreated), + "platform:admin (service account) should create internal role bindings; got %d: %s", resp.StatusCode(), resp.String()) +} + +// --- Valid Agent-Level Binding Test --- + +func TestCredentialBinding_AgentInProjectAccepted(t *testing.T) { + RegisterTestingT(t) + h := test.NewHelper(t) + h.DBFactory.ResetDB() + + username := "owner-user" + account := h.NewAccount(username, "Owner User", "owner@test.com") + ctx := h.NewAuthenticatedContext(account) + + credID, _ := setupCredentialBindingTest(t, h, username) + projectID, _ := setupProjectWithRole(t, username, "project:owner") + + // Create an agent IN this project + g := environments.Environment().Database.SessionFactory.New(context.Background()) + agentID := api.NewID() + err := g.Exec( + `INSERT INTO agents (id, project_id, name, created_at, updated_at) + VALUES (?, ?, ?, NOW(), NOW())`, + agentID, projectID, "correct-project-agent", + ).Error + Expect(err).NotTo(HaveOccurred()) + + var credReaderRoleID string + err = g.Raw(`SELECT id FROM roles WHERE name = 'credential:reader' AND deleted_at IS NULL`).Scan(&credReaderRoleID).Error + Expect(err).NotTo(HaveOccurred()) + + resp, err := createCredentialBinding(h, ctx, map[string]interface{}{ + "role_id": credReaderRoleID, + "scope": "credential", + "credential_id": credID, + "project_id": projectID, + "agent_id": agentID, + }) + Expect(err).NotTo(HaveOccurred()) + + // Parse the response to check the result + var body map[string]interface{} + _ = json.Unmarshal(resp.Body(), &body) + + Expect(resp.StatusCode()).To(Equal(http.StatusCreated), + "agent-level binding with correct project should succeed; got %d: %s", resp.StatusCode(), resp.String()) +} diff --git a/components/ambient-api-server/test/integration/integration_test.go b/components/ambient-api-server/test/integration/integration_test.go index d352febc9..4b5349eb2 100644 --- a/components/ambient-api-server/test/integration/integration_test.go +++ b/components/ambient-api-server/test/integration/integration_test.go @@ -11,7 +11,9 @@ import ( "github.com/ambient-code/platform/components/ambient-api-server/test" // Backend-compatible plugins only + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/agents" _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/credentials" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/inbox" _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/projectSettings" _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/projects" _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/rbac" diff --git a/components/ambient-api-server/test/integration/rbac_test.go b/components/ambient-api-server/test/integration/rbac_test.go index a47b60447..60227ae8e 100644 --- a/components/ambient-api-server/test/integration/rbac_test.go +++ b/components/ambient-api-server/test/integration/rbac_test.go @@ -26,13 +26,14 @@ func ensureBuiltInRoles(t *testing.T) { {"platform:admin", `["*:*"]`}, {"platform:viewer", `["project:read","project:list","session:read","session:list","agent:read","agent:list"]`}, {"project:owner", `["project:read","project:update","project:delete","agent:*","session:*","session_message:*","role_binding:*"]`}, - {"project:editor", `["project:read","agent:create","agent:read","agent:update","agent:list","agent:start","session:create","session:read","session:update","session:list","session_message:*"]`}, + {"project:editor", `["project:read","agent:create","agent:read","agent:update","agent:list","agent:start","session:create","session:read","session:update","session:list","session_message:*","role_binding:delete"]`}, {"project:viewer", `["project:read","agent:read","agent:list","session:read","session:list","session_message:read","session_message:list"]`}, {"agent:operator", `["agent:read","agent:update","agent:start","agent:list","session:read","session:list"]`}, {"agent:observer", `["agent:read","agent:list","session:read","session:list"]`}, {"agent:runner", `["session:read","session_message:*"]`}, {"agent:editor", `["agent:read","agent:update"]`}, {"credential:owner", `["credential:create","credential:read","credential:update","credential:delete","credential:list","credential:fetch_token","role_binding:create","role_binding:delete"]`}, + {"credential:reader", `["credential:read","credential:list"]`}, {"credential:token-reader", `["credential:fetch_token"]`}, } for _, r := range roles { diff --git a/components/ambient-control-plane/cmd/ambient-control-plane/main.go b/components/ambient-control-plane/cmd/ambient-control-plane/main.go index bac7d22d4..2b0ecd588 100644 --- a/components/ambient-control-plane/cmd/ambient-control-plane/main.go +++ b/components/ambient-control-plane/cmd/ambient-control-plane/main.go @@ -158,6 +158,7 @@ func runKubeMode(ctx context.Context, cfg *config.ControlPlaneConfig) error { ImagePullSecret: cfg.ImagePullSecret, PlatformMode: cfg.PlatformMode, MPPConfigNamespace: cfg.MPPConfigNamespace, + ServiceIdentity: cfg.ServiceIdentity, } conn, err := grpc.NewClient(cfg.GRPCServerAddr, grpc.WithTransportCredentials(grpcCredentials(cfg.GRPCUseTLS))) diff --git a/components/ambient-control-plane/internal/config/config.go b/components/ambient-control-plane/internal/config/config.go index dd3772e08..485ec158f 100755 --- a/components/ambient-control-plane/internal/config/config.go +++ b/components/ambient-control-plane/internal/config/config.go @@ -47,6 +47,7 @@ type ControlPlaneConfig struct { HTTPSProxy string NoProxy string ImagePullSecret string + ServiceIdentity string } func Load() (*ControlPlaneConfig, error) { @@ -91,6 +92,7 @@ func Load() (*ControlPlaneConfig, error) { HTTPSProxy: os.Getenv("HTTPS_PROXY"), NoProxy: os.Getenv("NO_PROXY"), ImagePullSecret: os.Getenv("IMAGE_PULL_SECRET"), + ServiceIdentity: strings.TrimSpace(os.Getenv("GRPC_SERVICE_ACCOUNT")), } if cfg.MCPAPIServerURL == "" { diff --git a/components/ambient-control-plane/internal/reconciler/credential_resolver_test.go b/components/ambient-control-plane/internal/reconciler/credential_resolver_test.go new file mode 100644 index 000000000..c950b4f93 --- /dev/null +++ b/components/ambient-control-plane/internal/reconciler/credential_resolver_test.go @@ -0,0 +1,414 @@ +package reconciler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + sdkclient "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/client" + "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" + "github.com/rs/zerolog" +) + +// mockAPIServer creates a test HTTP server that returns canned responses +// for role binding list and credential get endpoints. +type mockData struct { + roleBindings []types.RoleBinding + credentials map[string]types.Credential // keyed by ID +} + +func newMockAPIServer(data mockData) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + page := r.URL.Query().Get("page") + + if strings.HasPrefix(r.URL.Path, "/api/ambient/v1/role_bindings") && r.Method == "GET" { + search := r.URL.Query().Get("search") + filtered := filterBindings(data.roleBindings, search) + // Only return items on page 1; subsequent pages return empty to stop pagination + items := filtered + if page != "" && page != "1" { + items = nil + } + resp := map[string]interface{}{ + "kind": "RoleBindingList", + "page": 1, + "size": len(items), + "total": len(filtered), + "items": items, + } + json.NewEncoder(w).Encode(resp) + return + } + + if strings.HasPrefix(r.URL.Path, "/api/ambient/v1/credentials/") && r.Method == "GET" { + parts := strings.Split(r.URL.Path, "/") + credID := parts[len(parts)-1] + if cred, ok := data.credentials[credID]; ok { + json.NewEncoder(w).Encode(cred) + return + } + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) + return + } + + if strings.HasPrefix(r.URL.Path, "/api/ambient/v1/credentials") && r.Method == "GET" { + var creds []types.Credential + for _, c := range data.credentials { + creds = append(creds, c) + } + items := creds + if page != "" && page != "1" { + items = nil + } + resp := map[string]interface{}{ + "kind": "CredentialList", + "page": 1, + "size": len(items), + "total": len(creds), + "items": items, + } + json.NewEncoder(w).Encode(resp) + return + } + + w.WriteHeader(http.StatusNotFound) + })) +} + +// filterBindings does a basic TSL-like filter matching for test purposes. +func filterBindings(bindings []types.RoleBinding, search string) []types.RoleBinding { + if search == "" { + return bindings + } + var result []types.RoleBinding + for _, b := range bindings { + if matchesSearch(b, search) { + result = append(result, b) + } + } + return result +} + +// matchesSearch does simplified TSL matching sufficient for test queries. +func matchesSearch(b types.RoleBinding, search string) bool { + if strings.Contains(search, "scope = 'credential'") && b.Scope != "credential" { + return false + } + for _, part := range strings.Split(search, " and ") { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "project_id = '") { + val := extractTSLValue(part) + if b.ProjectID == nil || *b.ProjectID != val { + return false + } + } + if strings.HasPrefix(part, "agent_id = '") { + val := extractTSLValue(part) + if b.AgentID == nil || *b.AgentID != val { + return false + } + } + } + return true +} + +func extractTSLValue(part string) string { + start := strings.Index(part, "'") + end := strings.LastIndex(part, "'") + if start >= 0 && end > start { + return part[start+1 : end] + } + return "" +} + +func strPtr(s string) *string { return &s } +func timePtr(t time.Time) *time.Time { return &t } + +func newTestReconciler(logger zerolog.Logger) *SimpleKubeReconciler { + return &SimpleKubeReconciler{ + cfg: KubeReconcilerConfig{}, + logger: logger, + } +} + +func newSDKClient(t *testing.T, serverURL string) *sdkclient.Client { + t.Helper() + c, err := sdkclient.NewClient(serverURL, "test-token-must-be-at-least-20-chars-long", "test-project") + if err != nil { + t.Fatalf("failed to create SDK client: %v", err) + } + return c +} + +func TestResolveCredentialIDs_AgentLevelOverridesProject(t *testing.T) { + credA := "cred-a" + credB := "cred-b" + projectID := "proj-1" + agentID := "agent-1" + + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{ + { + ObjectReference: types.ObjectReference{ID: "rb-1", CreatedAt: timePtr(time.Now().Add(-2 * time.Hour))}, + Scope: "credential", + CredentialID: &credA, + ProjectID: &projectID, + }, + { + ObjectReference: types.ObjectReference{ID: "rb-2", CreatedAt: timePtr(time.Now().Add(-1 * time.Hour))}, + Scope: "credential", + CredentialID: &credB, + ProjectID: &projectID, + AgentID: &agentID, + }, + }, + credentials: map[string]types.Credential{ + credA: {ObjectReference: types.ObjectReference{ID: credA}, Provider: "github", Name: "cred-a"}, + credB: {ObjectReference: types.ObjectReference{ID: credB}, Provider: "github", Name: "cred-b"}, + }, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, projectID, agentID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result["github"] != credB { + t.Errorf("expected agent-level credential %s, got %s", credB, result["github"]) + } +} + +func TestResolveCredentialIDs_ProjectLevelFallback(t *testing.T) { + credA := "cred-a" + projectID := "proj-1" + agentID := "agent-1" + + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{ + { + ObjectReference: types.ObjectReference{ID: "rb-1", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credA, + ProjectID: &projectID, + // AgentID is nil → project-level binding + }, + }, + credentials: map[string]types.Credential{ + credA: {ObjectReference: types.ObjectReference{ID: credA}, Provider: "github", Name: "cred-a"}, + }, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, projectID, agentID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result["github"] != credA { + t.Errorf("expected project-level credential %s, got %s", credA, result["github"]) + } +} + +func TestResolveCredentialIDs_GlobalFallback(t *testing.T) { + credA := "cred-global" + + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{ + { + ObjectReference: types.ObjectReference{ID: "rb-global", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credA, + // ProjectID and AgentID nil → global + }, + }, + credentials: map[string]types.Credential{ + credA: {ObjectReference: types.ObjectReference{ID: credA}, Provider: "jira", Name: "global-jira"}, + }, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, "some-project", "some-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result["jira"] != credA { + t.Errorf("expected global credential %s, got %s", credA, result["jira"]) + } +} + +func TestResolveCredentialIDs_NoBindingNoInjection(t *testing.T) { + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{}, + credentials: map[string]types.Credential{}, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, "no-bindings-project", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(result) != 0 { + t.Errorf("expected empty result, got %v", result) + } +} + +func TestResolveCredentialIDs_MultipleProviders(t *testing.T) { + credGH := "cred-github" + credJira := "cred-jira" + projectID := "proj-multi" + agentID := "agent-multi" + + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{ + { + ObjectReference: types.ObjectReference{ID: "rb-gh", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credGH, + ProjectID: &projectID, + }, + { + ObjectReference: types.ObjectReference{ID: "rb-jira", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credJira, + ProjectID: &projectID, + AgentID: &agentID, + }, + }, + credentials: map[string]types.Credential{ + credGH: {ObjectReference: types.ObjectReference{ID: credGH}, Provider: "github", Name: "gh"}, + credJira: {ObjectReference: types.ObjectReference{ID: credJira}, Provider: "jira", Name: "jira"}, + }, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, projectID, agentID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result["github"] != credGH { + t.Errorf("expected github=%s, got %s", credGH, result["github"]) + } + if result["jira"] != credJira { + t.Errorf("expected jira=%s, got %s", credJira, result["jira"]) + } + if len(result) != 2 { + t.Errorf("expected 2 providers, got %d: %v", len(result), result) + } +} + +func TestResolveCredentialIDs_DuplicateSameScopeEarliestWins(t *testing.T) { + credOld := "cred-old" + credNew := "cred-new" + projectID := "proj-dup" + + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{ + { + ObjectReference: types.ObjectReference{ID: "rb-new", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credNew, + ProjectID: &projectID, + }, + { + ObjectReference: types.ObjectReference{ID: "rb-old", CreatedAt: timePtr(time.Now().Add(-24 * time.Hour))}, + Scope: "credential", + CredentialID: &credOld, + ProjectID: &projectID, + }, + }, + credentials: map[string]types.Credential{ + credOld: {ObjectReference: types.ObjectReference{ID: credOld}, Provider: "github", Name: "old-gh"}, + credNew: {ObjectReference: types.ObjectReference{ID: credNew}, Provider: "github", Name: "new-gh"}, + }, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, projectID, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result["github"] != credOld { + t.Errorf("expected earliest credential %s to win, got %s", credOld, result["github"]) + } +} + +func TestResolveCredentialIDs_AgentOverridesGlobal(t *testing.T) { + credGlobal := "cred-global" + credAgent := "cred-agent" + projectID := "proj-1" + agentID := "agent-1" + + server := newMockAPIServer(mockData{ + roleBindings: []types.RoleBinding{ + { + ObjectReference: types.ObjectReference{ID: "rb-global", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credGlobal, + }, + { + ObjectReference: types.ObjectReference{ID: "rb-agent", CreatedAt: timePtr(time.Now())}, + Scope: "credential", + CredentialID: &credAgent, + ProjectID: &projectID, + AgentID: &agentID, + }, + }, + credentials: map[string]types.Credential{ + credGlobal: {ObjectReference: types.ObjectReference{ID: credGlobal}, Provider: "github", Name: "global"}, + credAgent: {ObjectReference: types.ObjectReference{ID: credAgent}, Provider: "github", Name: "agent"}, + }, + }) + defer server.Close() + + logger := zerolog.New(zerolog.NewTestWriter(t)) + r := newTestReconciler(logger) + sdk := newSDKClient(t, server.URL) + + result, err := r.resolveCredentialIDs(context.Background(), sdk, projectID, agentID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result["github"] != credAgent { + t.Errorf("expected agent-level %s to override global, got %s", credAgent, result["github"]) + } +} + +// Suppress unused import warnings +var _ = fmt.Sprintf diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index b51733c56..0269ea9c3 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "regexp" + "sort" "strings" "time" @@ -16,6 +18,18 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ) +var safeTSLPattern = regexp.MustCompile(`^[a-zA-Z0-9_.@:\-]+$`) + +func validateTSLValue(value string) error { + if value == "" { + return nil + } + if !safeTSLPattern.MatchString(value) { + return fmt.Errorf("unsafe value for TSL query: %q", value) + } + return nil +} + const ( mcpSidecarPort = int64(8090) mcpSidecarURL = "http://localhost:8090" @@ -74,6 +88,7 @@ type KubeReconcilerConfig struct { ImagePullSecret string PlatformMode string MPPConfigNamespace string + ServiceIdentity string } type SimpleKubeReconciler struct { @@ -182,13 +197,19 @@ func (r *SimpleKubeReconciler) provisionSession(ctx context.Context, session typ return fmt.Errorf("ensuring service account: %w", err) } - credentialIDs, err := r.resolveCredentialIDs(ctx, sdk, session.ProjectID) + credentialIDs, err := r.resolveCredentialIDs(ctx, sdk, session.ProjectID, session.AgentID) if err != nil { r.logger.Warn().Err(err).Str("session_id", session.ID).Msg("credential resolution failed; continuing without credentials") credentialIDs = map[string]string{} } - if err := r.ensurePod(ctx, namespace, session, sessionLabel, sdk, credentialIDs); err != nil { + grantedIDs, grantErr := r.grantTokenReaderBindings(ctx, sdk, credentialIDs, session.ID) + if grantErr != nil { + r.logger.Warn().Err(grantErr).Str("session_id", session.ID).Msg("failed to create credential:token-reader bindings; continuing without credentials") + grantedIDs = map[string]string{} + } + + if err := r.ensurePod(ctx, namespace, session, sessionLabel, sdk, grantedIDs); err != nil { return fmt.Errorf("ensuring pod: %w", err) } @@ -206,11 +227,23 @@ func (r *SimpleKubeReconciler) deprovisionSession(ctx context.Context, session t r.logger.Info().Str("session_id", session.ID).Str("namespace", namespace).Msg("deprovisioning session") + var revokeErr error + if session.ProjectID != "" { + if sdk, err := r.factory.ForProject(ctx, session.ProjectID); err == nil { + revokeErr = r.revokeTokenReaderBindings(ctx, sdk, session.ID) + } else { + revokeErr = fmt.Errorf("failed to get SDK client for token-reader cleanup: %w", err) + } + } + if err := r.nsKube().DeletePodsByLabel(ctx, namespace, selector); err != nil && !k8serrors.IsNotFound(err) { r.logger.Warn().Err(err).Msg("deleting pods") } r.updateSessionPhase(ctx, session, nextPhase) + if revokeErr != nil { + return fmt.Errorf("session %s deprovisioned but token-reader cleanup failed: %w", session.ID, revokeErr) + } return nil } @@ -220,6 +253,15 @@ func (r *SimpleKubeReconciler) cleanupSession(ctx context.Context, session types r.logger.Info().Str("session_id", session.ID).Str("namespace", namespace).Msg("cleaning up session resources") + var revokeErr error + if session.ProjectID != "" { + if sdk, err := r.factory.ForProject(ctx, session.ProjectID); err == nil { + revokeErr = r.revokeTokenReaderBindings(ctx, sdk, session.ID) + } else { + revokeErr = fmt.Errorf("failed to get SDK client for token-reader cleanup: %w", err) + } + } + if err := r.nsKube().DeletePodsByLabel(ctx, namespace, selector); err != nil && !k8serrors.IsNotFound(err) { r.logger.Warn().Err(err).Msg("deleting pods") } @@ -239,6 +281,9 @@ func (r *SimpleKubeReconciler) cleanupSession(ctx context.Context, session types r.logger.Info().Str("namespace", namespace).Msg("namespace deprovisioned") } + if revokeErr != nil { + return fmt.Errorf("session %s cleaned up but token-reader cleanup failed: %w", session.ID, revokeErr) + } return nil } @@ -773,27 +818,203 @@ func (r *SimpleKubeReconciler) buildEnv(ctx context.Context, session types.Sessi return env } -func (r *SimpleKubeReconciler) resolveCredentialIDs(ctx context.Context, sdk *sdkclient.Client, projectID string) (map[string]string, error) { - result := map[string]string{} +func (r *SimpleKubeReconciler) resolveCredentialIDs(ctx context.Context, sdk *sdkclient.Client, projectID string, agentID ...string) (map[string]string, error) { + agent := "" + if len(agentID) > 0 { + agent = agentID[0] + } - it := sdk.Credentials().ListAll(ctx, &types.ListOptions{Size: 100}) - for it.Next() { - cred := it.Item() - if cred.Provider == "" || cred.ID == "" { + if err := validateTSLValue(projectID); err != nil { + return nil, fmt.Errorf("invalid project_id: %w", err) + } + if err := validateTSLValue(agent); err != nil { + return nil, fmt.Errorf("invalid agent_id: %w", err) + } + + // Look up credential:owner role ID to exclude ownership bindings from resolution. + // Ownership bindings (auto-created when a credential is created) share the same + // shape as global injection bindings but represent management authority, not + // injection intent. + var ownerRoleID string + ownerRoles, err := sdk.Roles().List(ctx, &types.ListOptions{Size: 1, Search: "name = 'credential:owner'"}) + if err == nil && len(ownerRoles.Items) > 0 { + ownerRoleID = ownerRoles.Items[0].ID + } + + isInjectionBinding := func(b types.RoleBinding) bool { + return ownerRoleID == "" || b.RoleID != ownerRoleID + } + + var agentBindings, projectBindings, globalBindings []types.RoleBinding + + // Agent-level bindings (most specific) + if agent != "" { + search := fmt.Sprintf("scope = 'credential' and project_id = '%s' and agent_id = '%s'", projectID, agent) + it := sdk.RoleBindings().ListAll(ctx, &types.ListOptions{Size: 100, Search: search}) + for it.Next() { + if b := it.Item(); isInjectionBinding(b) { + agentBindings = append(agentBindings, b) + } + } + if err := it.Err(); err != nil { + return nil, fmt.Errorf("listing agent-level credential bindings: %w", err) + } + } + + // Project-level bindings (filter out agent-level client-side since TSL lacks IS NULL) + projectSearch := fmt.Sprintf("scope = 'credential' and project_id = '%s'", projectID) + projectIt := sdk.RoleBindings().ListAll(ctx, &types.ListOptions{Size: 100, Search: projectSearch}) + for projectIt.Next() { + b := projectIt.Item() + if b.AgentID == nil && isInjectionBinding(b) { + projectBindings = append(projectBindings, b) + } + } + if err := projectIt.Err(); err != nil { + return nil, fmt.Errorf("listing project-level credential bindings: %w", err) + } + + // Global bindings (filter for NULL project_id and agent_id client-side) + globalIt := sdk.RoleBindings().ListAll(ctx, &types.ListOptions{Size: 100, Search: "scope = 'credential'"}) + for globalIt.Next() { + b := globalIt.Item() + if b.ProjectID == nil && b.AgentID == nil && isInjectionBinding(b) { + globalBindings = append(globalBindings, b) + } + } + if err := globalIt.Err(); err != nil { + return nil, fmt.Errorf("listing global credential bindings: %w", err) + } + + // If no bindings found, return empty result (no credentials injected) + totalBindings := len(agentBindings) + len(projectBindings) + len(globalBindings) + if totalBindings == 0 { + r.logger.Info().Str("project_id", projectID).Msg("no credential bindings found for project; no credentials will be injected") + return map[string]string{}, nil + } + + // Look up credential providers + allBindings := make([]types.RoleBinding, 0, totalBindings) + allBindings = append(allBindings, agentBindings...) + allBindings = append(allBindings, projectBindings...) + allBindings = append(allBindings, globalBindings...) + + credProviders := map[string]string{} + for _, b := range allBindings { + if b.CredentialID == nil { continue } - if _, already := result[cred.Provider]; !already { - result[cred.Provider] = cred.ID + if _, seen := credProviders[*b.CredentialID]; seen { + continue } + cred, err := sdk.Credentials().Get(ctx, *b.CredentialID) + if err != nil { + r.logger.Warn().Err(err).Str("credential_id", *b.CredentialID).Msg("failed to look up credential; skipping") + continue + } + credProviders[cred.ID] = cred.Provider } - if err := it.Err(); err != nil { - return nil, fmt.Errorf("listing credentials: %w", err) + + // Sort each tier by CreatedAt ascending so earliest wins for same provider + sortByCreatedAt := func(bindings []types.RoleBinding) { + sort.Slice(bindings, func(i, j int) bool { + if bindings[i].CreatedAt == nil { + return true + } + if bindings[j].CreatedAt == nil { + return false + } + return bindings[i].CreatedAt.Before(*bindings[j].CreatedAt) + }) } + sortByCreatedAt(globalBindings) + sortByCreatedAt(projectBindings) + sortByCreatedAt(agentBindings) - r.logger.Info().Int("count", len(result)).Msg("resolved credential IDs for session") + // Build result: layer global → project → agent (later tiers override) + result := map[string]string{} + applyTier := func(bindings []types.RoleBinding) { + seen := map[string]bool{} + for _, b := range bindings { + if b.CredentialID == nil { + continue + } + provider := credProviders[*b.CredentialID] + if provider == "" || seen[provider] { + continue + } + seen[provider] = true + result[provider] = *b.CredentialID + } + } + applyTier(globalBindings) + applyTier(projectBindings) + applyTier(agentBindings) + + r.logger.Info().Int("count", len(result)).Msg("resolved credential IDs via hierarchical bindings") return result, nil } +func (r *SimpleKubeReconciler) grantTokenReaderBindings(ctx context.Context, sdk *sdkclient.Client, credentialIDs map[string]string, sessionID string) (map[string]string, error) { + if len(credentialIDs) == 0 || r.cfg.ServiceIdentity == "" { + return credentialIDs, nil + } + + roleList, err := sdk.Roles().List(ctx, &types.ListOptions{Size: 1, Search: "name = 'credential:token-reader'"}) + if err != nil || len(roleList.Items) == 0 { + return nil, fmt.Errorf("credential:token-reader role not found: %w", err) + } + roleID := roleList.Items[0].ID + + granted := make(map[string]string, len(credentialIDs)) + for provider, credID := range credentialIDs { + rb, err := types.NewRoleBindingBuilder(). + RoleID(roleID). + Scope("credential"). + CredentialID(credID). + UserID(r.cfg.ServiceIdentity). + SessionID(sessionID). + Build() + if err != nil { + r.logger.Warn().Err(err).Str("provider", provider).Msg("failed to build token-reader binding") + continue + } + if _, err := sdk.RoleBindings().Create(ctx, rb); err != nil { + r.logger.Warn().Err(err).Str("provider", provider).Str("credential_id", credID).Msg("failed to create token-reader binding") + continue + } + granted[provider] = credID + r.logger.Info().Str("provider", provider).Str("credential_id", credID).Msg("granted credential:token-reader for session") + } + return granted, nil +} + +func (r *SimpleKubeReconciler) revokeTokenReaderBindings(ctx context.Context, sdk *sdkclient.Client, sessionID string) error { + if err := validateTSLValue(sessionID); err != nil { + return fmt.Errorf("invalid session_id: %w", err) + } + search := fmt.Sprintf("scope = 'credential' and session_id = '%s'", sessionID) + it := sdk.RoleBindings().ListAll(ctx, &types.ListOptions{Size: 100, Search: search}) + var errs []error + for it.Next() { + b := it.Item() + if err := sdk.RoleBindings().Delete(ctx, b.ID); err != nil { + r.logger.Warn().Err(err).Str("binding_id", b.ID).Msg("failed to delete token-reader binding") + errs = append(errs, err) + } else { + r.logger.Info().Str("binding_id", b.ID).Str("session_id", sessionID).Msg("revoked credential:token-reader binding") + } + } + if err := it.Err(); err != nil { + r.logger.Warn().Err(err).Str("session_id", sessionID).Msg("error listing token-reader bindings for cleanup") + errs = append(errs, err) + } + if len(errs) > 0 { + return fmt.Errorf("failed to revoke %d token-reader binding(s)", len(errs)) + } + return nil +} + func (r *SimpleKubeReconciler) assembleInitialPrompt(ctx context.Context, session types.Session, sdk *sdkclient.Client) string { var parts []string