ultisuite-backend/internal/permission/permission.go
R3D347HR4Y cd0a80f5e8 huhu
2026-05-25 13:52:27 +02:00

241 lines
5.2 KiB
Go

package permission
import "strings"
// Role is a platform-level role carried in OIDC groups.
type Role string
const (
RoleAdmin Role = "admin"
RoleUser Role = "user"
RoleService Role = "service"
)
// Resource is a suite module protected by resource-scoped permissions.
type Resource string
const (
ResourceDrive Resource = "drive"
ResourcePhotos Resource = "photos"
ResourceContacts Resource = "contacts"
ResourceCalendar Resource = "calendar"
)
// Level is a resource permission with read < write < admin ordering.
type Level int
const (
LevelRead Level = iota + 1
LevelWrite
LevelAdmin
)
func (l Level) String() string {
switch l {
case LevelRead:
return "read"
case LevelWrite:
return "write"
case LevelAdmin:
return "admin"
default:
return "unknown"
}
}
func ParseLevel(s string) (Level, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "read":
return LevelRead, true
case "write":
return LevelWrite, true
case "admin":
return LevelAdmin, true
default:
return 0, false
}
}
func levelRank(l Level) int {
return int(l)
}
var suiteResources = []Resource{
ResourceContacts,
ResourceCalendar,
ResourceDrive,
ResourcePhotos,
}
func hasAnyResourcePermission(groups []string) bool {
for _, g := range groups {
g = strings.ToLower(strings.TrimSpace(g))
for _, resource := range suiteResources {
if strings.HasPrefix(g, string(resource)+":") {
return true
}
}
}
return false
}
// WithSuiteDefaults grants standard suite read/write when the token carries no
// resource-scoped groups. Mail endpoints stay open; CardDAV/CalDAV modules rely
// on this until Authentik emits explicit RBAC groups on every account.
func WithSuiteDefaults(groups []string) []string {
if hasAnyResourcePermission(groups) {
return groups
}
defaults := []string{
string(RoleUser),
string(ResourceContacts) + ":write",
string(ResourceCalendar) + ":write",
string(ResourceDrive) + ":write",
string(ResourcePhotos) + ":write",
}
seen := make(map[string]struct{}, len(groups)+len(defaults))
out := make([]string, 0, len(groups)+len(defaults))
for _, g := range groups {
g = strings.TrimSpace(g)
if g == "" {
continue
}
key := strings.ToLower(g)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, g)
}
for _, g := range defaults {
key := strings.ToLower(g)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, g)
}
return out
}
// AdminScope is a fine-grained admin API permission with read < write ordering.
type AdminScope int
const (
AdminScopeRead AdminScope = iota + 1
AdminScopeWrite
)
// DefaultAdminScope is the scope assumed when an endpoint requires full admin API access.
const DefaultAdminScope = AdminScopeWrite
const (
GroupAdminRead = "admin:read"
GroupAdminWrite = "admin:write"
)
func (s AdminScope) String() string {
switch s {
case AdminScopeRead:
return "read"
case AdminScopeWrite:
return "write"
default:
return "unknown"
}
}
func ParseAdminScope(s string) (AdminScope, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "read":
return AdminScopeRead, true
case "write":
return AdminScopeWrite, true
default:
return 0, false
}
}
func adminScopeRank(s AdminScope) int {
return int(s)
}
// HasAdminScope reports whether groups grant at least the required admin API scope.
// Platform admins (admin or role:admin) satisfy any scope for backwards compatibility.
// admin:write implies admin:read; admin:read does not imply write.
func HasAdminScope(groups []string, required AdminScope) bool {
if HasRole(groups, RoleAdmin) {
return true
}
max := AdminScope(0)
for _, g := range groups {
g = strings.ToLower(strings.TrimSpace(g))
switch g {
case GroupAdminRead:
if adminScopeRank(AdminScopeRead) > adminScopeRank(max) {
max = AdminScopeRead
}
case GroupAdminWrite:
if adminScopeRank(AdminScopeWrite) > adminScopeRank(max) {
max = AdminScopeWrite
}
}
}
return adminScopeRank(max) >= adminScopeRank(required)
}
// HasRole reports whether groups grant the given platform role.
func HasRole(groups []string, role Role) bool {
want := string(role)
for _, g := range groups {
g = strings.ToLower(strings.TrimSpace(g))
if g == want || g == "role:"+want {
return true
}
}
return false
}
// HasAnyRole reports whether groups grant at least one of the roles.
func HasAnyRole(groups []string, roles ...Role) bool {
for _, role := range roles {
if HasRole(groups, role) {
return true
}
}
return false
}
// HasPermission reports whether groups grant at least the required level on resource.
// Platform admins bypass resource checks. Higher levels satisfy lower ones.
func HasPermission(groups []string, resource Resource, required Level) bool {
if HasRole(groups, RoleAdmin) {
return true
}
want := string(resource)
max := Level(0)
for _, g := range groups {
g = strings.ToLower(strings.TrimSpace(g))
prefix := want + ":"
if !strings.HasPrefix(g, prefix) {
continue
}
level, ok := ParseLevel(strings.TrimPrefix(g, prefix))
if !ok {
continue
}
if levelRank(level) > levelRank(max) {
max = level
}
}
return levelRank(max) >= levelRank(required)
}