// Package access implements uopi's global user-access policy: every user is // trusted (full write) by default, while a configured blacklist can downgrade // specific users to read-only or no access. It also resolves the per-request // user identity and the user→group memberships defined in config. // // This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is // layered on top of this in a later phase. package access import ( "context" "sort" "strings" ) // Level is a global access level. Higher levels include lower ones. type Level int const ( // LevelNone denies all access. LevelNone Level = iota // LevelRead permits reads only (no create/update/delete/share, no signal writes). LevelRead // LevelWrite permits full access. This is the default for any user not blacklisted. LevelWrite ) // String renders the level using the same tokens accepted by ParseLevel and // surfaced to the frontend via /api/v1/me. func (l Level) String() string { switch l { case LevelNone: return "none" case LevelRead: return "readonly" default: return "write" } } // ParseLevel maps a config string to a Level. Unknown values restrict to // read-only, since the only reason to list a user is to limit them. func ParseLevel(s string) Level { switch strings.ToLower(strings.TrimSpace(s)) { case "noaccess", "none", "no": return LevelNone case "readonly", "read", "ro": return LevelRead case "write", "readwrite", "rw", "full": return LevelWrite default: return LevelRead } } // Policy holds the resolved global access configuration. It is immutable after // construction and safe for concurrent use. type Policy struct { defaultUser string blacklist map[string]Level // user → downgraded level userGroups map[string][]string // user → groups they belong to groupNames []string // all configured group names (sorted) logicEditors map[string]bool // users + group names allowed to edit logic auditReaders map[string]bool // users + group names allowed to view the audit log } // New builds a Policy. blacklist maps a username to a config level string; // groups maps a group name to its member usernames. logicEditors optionally // restricts who may edit panel/control logic (usernames or group names); empty // means no restriction. func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy { p := &Policy{ defaultUser: strings.TrimSpace(defaultUser), blacklist: make(map[string]Level), userGroups: make(map[string][]string), logicEditors: make(map[string]bool), auditReaders: make(map[string]bool), } for _, e := range logicEditors { e = strings.TrimSpace(e) if e != "" { p.logicEditors[e] = true } } for _, e := range auditReaders { e = strings.TrimSpace(e) if e != "" { p.auditReaders[e] = true } } for user, lvl := range blacklist { u := strings.TrimSpace(user) if u == "" { continue } p.blacklist[u] = ParseLevel(lvl) } for g, members := range groups { g = strings.TrimSpace(g) if g == "" { continue } p.groupNames = append(p.groupNames, g) for _, m := range members { m = strings.TrimSpace(m) if m == "" { continue } p.userGroups[m] = append(p.userGroups[m], g) } } sort.Strings(p.groupNames) return p } // GroupNames returns a copy of every configured user-group name, sorted. func (p *Policy) GroupNames() []string { out := make([]string, len(p.groupNames)) copy(out, p.groupNames) return out } // ResolveUser trims the proxy-provided header value and falls back to the // configured default_user when it is empty (e.g. unproxied/dev deployments). func (p *Policy) ResolveUser(headerValue string) string { u := strings.TrimSpace(headerValue) if u == "" { return p.defaultUser } return u } // Level returns the global access level for a user. Users that are neither // blacklisted nor anonymous get full write access. func (p *Policy) Level(user string) Level { user = strings.TrimSpace(user) if user == "" { // No identity at all (no proxy header, no default_user): trusted LAN. return LevelWrite } if lvl, ok := p.blacklist[user]; ok { return lvl } return LevelWrite } // LogicRestricted reports whether a logic-editor allowlist is configured. When // false, any write-capable user may edit panel/control logic. func (p *Policy) LogicRestricted() bool { return len(p.logicEditors) > 0 } // CanEditLogic reports whether a user may add or edit panel logic and // server-side control logic. When no allowlist is configured everyone with // write access qualifies; otherwise the user (or one of their groups) must be // listed. Anonymous/trusted-LAN callers (user=="") are always permitted. func (p *Policy) CanEditLogic(user string) bool { user = strings.TrimSpace(user) if user == "" { return true } if !p.LogicRestricted() { return true } if p.logicEditors[user] { return true } for _, g := range p.userGroups[user] { if p.logicEditors[g] { return true } } return false } // CanViewAudit reports whether a user may view the audit log. When no reader // allowlist is configured everyone with read access qualifies; otherwise the // user (or one of their groups) must be listed. Anonymous/trusted-LAN callers // (user=="") are always permitted. func (p *Policy) CanViewAudit(user string) bool { user = strings.TrimSpace(user) if user == "" { return true } if len(p.auditReaders) == 0 { return true } if p.auditReaders[user] { return true } for _, g := range p.userGroups[user] { if p.auditReaders[g] { return true } } return false } // GroupsOf returns a copy of the groups a user belongs to. func (p *Policy) GroupsOf(user string) []string { src := p.userGroups[strings.TrimSpace(user)] out := make([]string, len(src)) copy(out, src) return out } // ── request-scoped user identity ──────────────────────────────────────────── type ctxKey struct{} // WithUser returns a copy of ctx carrying the resolved end-user identity. func WithUser(ctx context.Context, user string) context.Context { return context.WithValue(ctx, ctxKey{}, user) } // UserFrom returns the identity stored by WithUser, or "" if none. func UserFrom(ctx context.Context) string { u, _ := ctx.Value(ctxKey{}).(string) return u }