From 4e07b501a49779846138d2f4e887679af29330b1 Mon Sep 17 00:00:00 2001 From: nfebe Date: Mon, 25 May 2026 20:25:37 +0100 Subject: [PATCH] fix(auth): Honor deployment scope on keys minted by admin users A deployment-scoped API key minted by an admin user was rejected with 403 when used. The check required an explicit per-deployment grant on the underlying user record, which admin users do not have because their access is granted implicitly by role. Admins now satisfy the user side of the check unconditionally, and the key's per-deployment scope is applied as a cap on top, so a scoped key works as intended from any user role. The mirror condition is fixed too: an admin-role key with a per-deployment scope is now capped by that scope rather than short-circuiting the check. A scoped key cannot grant more access than either side of the pair allows; the effective level is the lower of the two. --- internal/auth/models.go | 66 +++++++++++++++++++-------- internal/auth/models_test.go | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 19 deletions(-) diff --git a/internal/auth/models.go b/internal/auth/models.go index 6bb67a9..13b874b 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -158,35 +158,63 @@ func (a *ActorContext) HasPermission(p Permission) bool { } func (a *ActorContext) CanAccessDeployment(name string, requiredLevel string) bool { - if a.Role == RoleAdmin { - return true + userLevel := actorUserDeploymentLevel(a, name) + if userLevel == "" { + return false } - if a.APIKey != nil && len(a.APIKey.Deployments) > 0 { - keyLevel, ok := a.APIKey.Deployments[name] - if !ok { - return false - } - if !accessLevelSufficient(keyLevel, requiredLevel) { - return false - } + keyLevel := actorAPIKeyDeploymentLevel(a, name) + if keyLevel == "" { + return false } - level, ok := a.Deployments[name] - if !ok { - return false + return accessLevelSufficient(minAccessLevel(userLevel, keyLevel), requiredLevel) +} + +func actorUserDeploymentLevel(a *ActorContext, name string) string { + if a.User != nil && a.User.Role == RoleAdmin { + return AccessLevelAdmin + } + if a.User == nil && a.Role == RoleAdmin { + return AccessLevelAdmin } + if lvl, ok := a.Deployments[name]; ok { + return lvl + } + return "" +} - return accessLevelSufficient(level, requiredLevel) +func actorAPIKeyDeploymentLevel(a *ActorContext, name string) string { + if a.APIKey == nil || len(a.APIKey.Deployments) == 0 { + return AccessLevelAdmin + } + if lvl, ok := a.APIKey.Deployments[name]; ok { + return lvl + } + return "" } func accessLevelSufficient(has, required string) bool { - levels := map[string]int{ - AccessLevelRead: 1, - AccessLevelWrite: 2, - AccessLevelAdmin: 3, + return accessLevelRank(has) >= accessLevelRank(required) +} + +func minAccessLevel(a, b string) string { + if accessLevelRank(a) <= accessLevelRank(b) { + return a + } + return b +} + +func accessLevelRank(level string) int { + switch level { + case AccessLevelRead: + return 1 + case AccessLevelWrite: + return 2 + case AccessLevelAdmin: + return 3 } - return levels[has] >= levels[required] + return 0 } func (a *APIKey) GetPermissionsJSON() string { diff --git a/internal/auth/models_test.go b/internal/auth/models_test.go index 93d6fbb..e4d80f1 100644 --- a/internal/auth/models_test.go +++ b/internal/auth/models_test.go @@ -136,6 +136,93 @@ func TestActorContextCanAccessDeployment(t *testing.T) { requiredLevel: "write", want: true, }, + { + name: "admin user with deployment-scoped non-admin key gets the key's level", + actor: &ActorContext{ + User: &User{Role: RoleAdmin}, + Role: RoleOperator, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelWrite}}, + }, + deploymentName: "my-app", + requiredLevel: "write", + want: true, + }, + { + name: "admin user with deployment-scoped key cannot exceed the key's level", + actor: &ActorContext{ + User: &User{Role: RoleAdmin}, + Role: RoleOperator, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelWrite}}, + }, + deploymentName: "my-app", + requiredLevel: "admin", + want: false, + }, + { + name: "admin user with deployment-scoped key denies unlisted deployment", + actor: &ActorContext{ + User: &User{Role: RoleAdmin}, + Role: RoleOperator, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelWrite}}, + }, + deploymentName: "other-app", + requiredLevel: "read", + want: false, + }, + { + name: "admin-role key with deployment scope is capped by the scope", + actor: &ActorContext{ + User: &User{Role: RoleAdmin}, + Role: RoleAdmin, + APIKey: &APIKey{Role: RoleAdmin, Deployments: DeploymentAccess{"my-app": AccessLevelWrite}}, + }, + deploymentName: "my-app", + requiredLevel: "admin", + want: false, + }, + { + name: "admin user with unscoped key keeps admin access", + actor: &ActorContext{ + User: &User{Role: RoleAdmin}, + Role: RoleOperator, + APIKey: &APIKey{}, + }, + deploymentName: "anything", + requiredLevel: "admin", + want: true, + }, + { + name: "operator user cannot gain access via the key alone", + actor: &ActorContext{ + User: &User{Role: RoleOperator}, + Role: RoleOperator, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelWrite}}, + }, + deploymentName: "my-app", + requiredLevel: "read", + want: false, + }, + { + name: "operator user with both grants takes the lower level", + actor: &ActorContext{ + User: &User{Role: RoleOperator}, + Role: RoleOperator, + Deployments: map[string]string{"my-app": "read"}, + APIKey: &APIKey{Deployments: DeploymentAccess{"my-app": AccessLevelAdmin}}, + }, + deploymentName: "my-app", + requiredLevel: "write", + want: false, + }, + { + name: "anonymous admin (no user, no key) keeps admin access", + actor: &ActorContext{ + Role: RoleAdmin, + }, + deploymentName: "any", + requiredLevel: "admin", + want: true, + }, } for _, tt := range tests {