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 {