Feat admin rbac migration (#1)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
@@ -2,29 +2,35 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// EveryoneGroupID is the stable ID of the implicit "Everyone" group seeded in
|
||||
// migration 020. Every authenticated user receives its permissions without an
|
||||
// migration 002. Every authenticated user receives its permissions without an
|
||||
// explicit membership row.
|
||||
const EveryoneGroupID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// AdminsGroupID is the stable ID of the "Admins" system group seeded in
|
||||
// migration 002. Members receive surface.admin.access and all platform
|
||||
// permissions. Replaces the legacy users.role = 'admin' check.
|
||||
const AdminsGroupID = "00000000-0000-0000-0000-000000000002"
|
||||
|
||||
// Permission constants — domain.action convention.
|
||||
const (
|
||||
PermExtensionUse = "extension.use" // use installed extensions
|
||||
PermExtensionInstall = "extension.install" // install/manage extension packages
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions
|
||||
PermWorkflowSubmit = "workflow.submit" // submit to public workflows
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
PermSurfaceAdminAccess = "surface.admin.access" // full admin panel access (replaces role check)
|
||||
PermExtensionUse = "extension.use" // use installed extensions
|
||||
PermExtensionInstall = "extension.install" // install/manage extension packages
|
||||
PermWorkflowCreate = "workflow.create" // create workflow definitions
|
||||
PermWorkflowSubmit = "workflow.submit" // submit to public workflows
|
||||
PermAdminView = "admin.view" // read-only admin panel access
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
)
|
||||
|
||||
// AllPermissions is the complete set of valid permission strings.
|
||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
||||
var AllPermissions = []string{
|
||||
PermSurfaceAdminAccess,
|
||||
PermExtensionUse,
|
||||
PermExtensionInstall,
|
||||
PermWorkflowCreate,
|
||||
@@ -36,19 +42,10 @@ var AllPermissions = []string{
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
// ResolvePermissions returns the effective permission set for a user.
|
||||
// Always includes the Everyone group regardless of membership.
|
||||
// Callers should short-circuit for role=admin before calling this.
|
||||
// Unions permissions from all groups the user is a member of (including Everyone).
|
||||
func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
perms := make(map[string]bool)
|
||||
|
||||
// Always apply Everyone group — no membership row required.
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
for _, p := range everyone.Permissions {
|
||||
perms[p] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Union all explicit group memberships.
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return perms, err
|
||||
@@ -62,91 +59,19 @@ func ResolvePermissions(ctx context.Context, stores store.Stores, userID string)
|
||||
return perms, nil
|
||||
}
|
||||
|
||||
// ── Token Budget ────────────────────────────
|
||||
|
||||
// TokenBudget holds the resolved ceiling for a user and the time window it covers.
|
||||
type TokenBudget struct {
|
||||
Limit int64
|
||||
Period string // "daily" or "monthly"
|
||||
// EnsureEveryoneGroup adds a user to the Everyone group (idempotent).
|
||||
// Called on every user creation path so that Everyone membership is explicit.
|
||||
func EnsureEveryoneGroup(ctx context.Context, stores store.Stores, userID string) {
|
||||
_ = stores.Groups.AddMember(ctx, EveryoneGroupID, userID, userID)
|
||||
}
|
||||
|
||||
// ResolveTokenBudget returns the most restrictive token budget across all of the
|
||||
// user's groups. Returns nil if no group has a budget set (unrestricted).
|
||||
// Callers should skip budget enforcement when providerScope == "personal" (BYOK).
|
||||
func ResolveTokenBudget(ctx context.Context, stores store.Stores, userID string) (*TokenBudget, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
isNewDay := now.Hour() < 1
|
||||
_ = isNewDay // used by callers for cache invalidation hints if needed
|
||||
|
||||
var daily, monthly *int64
|
||||
for _, g := range groups {
|
||||
if g.TokenBudgetDaily != nil {
|
||||
if daily == nil || *g.TokenBudgetDaily < *daily {
|
||||
daily = g.TokenBudgetDaily
|
||||
}
|
||||
}
|
||||
if g.TokenBudgetMonthly != nil {
|
||||
if monthly == nil || *g.TokenBudgetMonthly < *monthly {
|
||||
monthly = g.TokenBudgetMonthly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the most restrictive window. Daily wins when both are set
|
||||
// and daily is the binding constraint.
|
||||
if daily != nil {
|
||||
return &TokenBudget{Limit: *daily, Period: "daily"}, nil
|
||||
}
|
||||
if monthly != nil {
|
||||
return &TokenBudget{Limit: *monthly, Period: "monthly"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
// AddToAdminsGroup adds a user to the Admins group (idempotent).
|
||||
func AddToAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
|
||||
_ = stores.Groups.AddMember(ctx, AdminsGroupID, userID, userID)
|
||||
}
|
||||
|
||||
// ── Model Allowlist ─────────────────────────
|
||||
|
||||
// ResolveModelAllowlist returns the union of allowed model IDs across all of the
|
||||
// user's groups. Returns nil if the user is unrestricted (any group has a nil
|
||||
// allowlist, which means "no restriction").
|
||||
func ResolveModelAllowlist(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
groups, err := stores.Groups.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If any group has nil AllowedModels, the user is unrestricted.
|
||||
for _, g := range groups {
|
||||
if g.AllowedModels == nil {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// All groups have explicit allowlists — union them.
|
||||
allowed := make(map[string]bool)
|
||||
for _, g := range groups {
|
||||
for _, m := range g.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
// No groups at all → fall through to Everyone group check.
|
||||
if len(groups) == 0 {
|
||||
if everyone, err := stores.Groups.GetByID(ctx, EveryoneGroupID); err == nil {
|
||||
if everyone.AllowedModels == nil {
|
||||
return nil, nil // Everyone has no restriction
|
||||
}
|
||||
for _, m := range everyone.AllowedModels {
|
||||
allowed[m] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return nil, nil // empty allowlists across all groups = unrestricted
|
||||
}
|
||||
return allowed, nil
|
||||
// RemoveFromAdminsGroup removes a user from the Admins group.
|
||||
func RemoveFromAdminsGroup(ctx context.Context, stores store.Stores, userID string) {
|
||||
_ = stores.Groups.RemoveMember(ctx, AdminsGroupID, userID)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user