221 lines
6.3 KiB
Go
221 lines
6.3 KiB
Go
// 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
|
|
}
|