package auth import ( "context" "strings" ) // CloudRole captures the user's tier under Radar Cloud's RBAC model. // Cloud injects one of `radar:owner` / `radar:member` / `radar:viewer` // into X-Forwarded-Groups (plus the deprecated `cloud:*` equivalents for // the transition window); this type extracts that signal. // // CloudRole gates Radar's own features — settings, integrations, self-upgrade — // which run as Radar's service account, so nothing else checks them. It must // not gate operations that run as the impersonated user (Helm, probe pods): // Kubernetes RBAC decides those, and when IdP groups grant access the role // says nothing about what the user may do in the cluster. type CloudRole string const ( // RoleNone means the request is not from a Cloud-attributed user // (OSS deploy, or a Cloud bug stripped the role group). AtLeast // returns true for RoleNone so non-Cloud deploys aren't gated. RoleNone CloudRole = "" RoleViewer CloudRole = "viewer" RoleMember CloudRole = "member" RoleOwner CloudRole = "owner" ) // radar: is the canonical vocabulary; cloud: is the pre-rename legacy still // emitted by the hub (and possibly present from older hubs) during the // deprecation window. Accepting both keeps every hub/binary version pairing // working; drop cloud: in the next major. var tierGroupPrefixes = []string{"radar:", "cloud:"} // ErrCodeCloudRoleInsufficient is the stable wire value emitted in 403 // response bodies (`error_code` field) when a request is denied by a // Cloud role gate. frontend + MCP clients branch on this exact string — // never rename. Hoisted out of internal/helm/handlers.go so the // canonical value lives next to the role types it pertains to. const ErrCodeCloudRoleInsufficient = "cloud_role_insufficient" var tierRank = map[CloudRole]int{ RoleViewer: 1, RoleMember: 2, RoleOwner: 3, } // CloudRoleFromGroups returns the highest-ranked Cloud tier present in // the group list (accepting both the canonical radar: and legacy // cloud: forms). Returns RoleNone when no tier group is found, // which the call sites treat as "not running under Cloud." func CloudRoleFromGroups(groups []string) CloudRole { var best CloudRole bestRank := 0 for _, g := range groups { for _, prefix := range tierGroupPrefixes { if !strings.HasPrefix(g, prefix) { continue } role := CloudRole(strings.TrimPrefix(g, prefix)) rank, ok := tierRank[role] if !ok { continue } if rank > bestRank { best = role bestRank = rank } break } } return best } // CloudRoleFromContext is a convenience over UserFromContext + // CloudRoleFromGroups. Returns RoleNone when no user is in context. func CloudRoleFromContext(ctx context.Context) CloudRole { user := UserFromContext(ctx) if user == nil { return RoleNone } if user.HubGroups != nil { return CloudRoleFromGroups(user.HubGroups) } return CloudRoleFromGroups(user.Groups) } // AtLeast reports whether r meets or exceeds min. RoleNone (no Cloud // role detected) bypasses the gate — non-Cloud deploys must not be // blocked by Cloud-tier checks. func (r CloudRole) AtLeast(min CloudRole) bool { if r == RoleNone { return true } return tierRank[r] >= tierRank[min] } // String returns the role name suitable for log/error messages. func (r CloudRole) String() string { if r == RoleNone { return "none" } return string(r) }