diff --git a/Makefile b/Makefile index bda899c..8eb2a63 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all frontend backend backend-debug release catools test bench race lint clean run +.PHONY: all frontend backend backend-debug backend-pam release catools test bench race lint clean run # --------------------------------------------------------------------------- # # Sources — adding any file here triggers a frontend or backend rebuild # @@ -51,6 +51,14 @@ $(CATOOLS): $(GO_SRCS) @mkdir -p dist CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools +# PAM-enabled binary: adds built-in HTTP Basic authentication validated against +# the host PAM stack (server.basic_auth). Requires cgo + libpam (-dev headers), +# so the resulting binary is NOT fully static — use only when basic_auth is needed. +backend-pam: $(FRONTEND_OUT) + @mkdir -p dist + CGO_ENABLED=1 go build -tags pam -ldflags="-s -w" -o $(BINARY) ./cmd/uopi + CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools + # Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0) backend-debug: $(FRONTEND_OUT) @mkdir -p dist diff --git a/TODO.md b/TODO.md index 94da58f..8b91265 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,8 @@ # TODO +- **BUG FIX**: + - [x] connecting from firefox always get default user — native SPNEGO/Kerberos auth (`[server.kerberos]`, `internal/server/kerberos.go`): uopi challenges with `401 Negotiate` and resolves the user from the validated ticket instead of relying on a proxy header Firefox never triggers; on non-Kerberos hosts (SSSD/LDAP) use standalone built-in HTTP Basic auth — either validated via PAM (`[server.basic_auth]`, `internal/pamauth`, build with `make backend-pam`) or, keeping the static binary, pure-Go LDAP search-then-bind (`[server.ldap]`, `internal/ldapauth`) — sharing `internal/server/basicauth.go` (which also challenges the page load so the browser shows its login dialog) with optional built-in TLS (`[server.tls]`) + - [x] dpi scaling seems not to work on firefox — root font-size now folds in `window.devicePixelRatio` (`applyZoom` = 16×zoom×dpr) with `watchDpr()` re-applying on monitor change - [ ] **MAJOR** Implement configuration manager: - configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system - with configuration manager user should be able to: @@ -75,11 +78,11 @@ - [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit) - [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `
  • `, action buttons `stopPropagation` (`InterfaceList.tsx`) - [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer) - - [ ] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config) - - [ ] panel tree by user / group / global - - [ ] signal tree by user / group / global - - [ ] config tree by user / group / global - - [ ] control sequence by user / group / global + - [x] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config). Uniform scope model: each item carries owner + scope ∈ {private,group,global} + scope-groups; empty/unknown scope = global (legacy-safe). Shared Go helper `access.CanSee` (`internal/access/scope.go`) filters list endpoints by the caller; shared frontend `web/src/lib/scope.tsx` provides `bucketOf`/`filterByScope`, the segmented `[Mine | Group ▾ | Global]` `ScopeFilter`, and the `ScopePicker` create/save visibility editor. Visibility is a selector filter, not a hard security boundary (owner always sees own items) + - [x] panel tree by user / group / global — scope bucket derived server-side from the panel ACL (`panelScope` in `internal/api/api.go`: public→global, group grant→group, else→private; unmanaged→global), surfaced on `InterfaceListItem.scope`/`groups`; `InterfaceList.tsx` filters panels by bucket and hides folders with no in-scope descendants; visibility is edited via the existing Share dialog + - [x] signal tree by user / group / global — synthetic `SignalDef` gained a `group` visibility mode + `Groups[]`; `synVisible` (`api.go`) routes it through `access.CanSee`; `SyntheticGraphEditor.tsx` wizard offers panel/user/group/global with group checkboxes + - [x] config tree by user / group / global — `ConfigSet`/`ConfigInstance` carry owner+scope+groups (`internal/confmgr`), list endpoints filter via `filterConfigMetas`/`CanSee`, owner preserved across updates; `ConfigManager.tsx` adds `ScopeFilter` + `ScopePicker` to both Sets and Instances managers + - [x] control sequence by user / group / global — control-logic `Graph` gained owner+scope+scopeGroups (`internal/controllogic/model.go`; named `scopeGroups` to avoid the existing cosmetic `Groups []NodeGroup`); `listControlLogic` filters via `CanSee`, create/update stamp/preserve owner; `ControlLogicEditor.tsx` adds `ScopeFilter` + `ScopePicker` (`filterByScope(..., g => g.scopeGroups ?? [])`) - [ ] Node editors: - [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval) - [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors) diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 44d0db2..cefe3e9 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -13,6 +13,7 @@ import ( "path/filepath" "syscall" + "github.com/jcmturner/gokrb5/v8/keytab" "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" @@ -24,6 +25,8 @@ import ( "github.com/uopi/uopi/internal/datasource/servervar" "github.com/uopi/uopi/internal/datasource/stub" "github.com/uopi/uopi/internal/datasource/synthetic" + "github.com/uopi/uopi/internal/ldapauth" + "github.com/uopi/uopi/internal/pamauth" "github.com/uopi/uopi/internal/panelacl" "github.com/uopi/uopi/internal/server" "github.com/uopi/uopi/internal/storage" @@ -220,7 +223,93 @@ func main() { ctrlEngine.SetDebugObserver(debugHub) ctrlEngine.Reload() - srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log) + // Native SPNEGO/Kerberos authentication (optional): load the service keytab so + // the server can validate browser Negotiate tickets and resolve users directly. + var krbKeytab *keytab.Keytab + if cfg.Server.Kerberos.Enabled { + if cfg.Server.Kerberos.Keytab == "" { + log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)") + os.Exit(1) + } + kt, err := keytab.Load(cfg.Server.Kerberos.Keytab) + if err != nil { + log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err) + os.Exit(1) + } + krbKeytab = kt + log.Info("native SPNEGO/Kerberos authentication enabled", + "keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal) + } + + // Built-in HTTP Basic authentication (optional): challenge the browser with a + // login dialog and validate credentials against either the host PAM stack + // (basic_auth) or an LDAP directory (ldap), so users log in with their normal + // accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are + // mutually exclusive. + var basicAuthFn func(user, pass string) error + var basicAuthRealm string + enabledAuth := 0 + for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} { + if on { + enabledAuth++ + } + } + if enabledAuth > 1 { + log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one") + os.Exit(1) + } + if cfg.Server.BasicAuth.Enabled { + if !pamauth.Available { + log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)") + os.Exit(1) + } + service := cfg.Server.BasicAuth.PAMService + if service == "" { + service = "uopi" + } + basicAuthFn = func(user, pass string) error { + return pamauth.Authenticate(service, user, pass) + } + basicAuthRealm = "uopi" + log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service) + } + if cfg.Server.LDAP.Enabled { + ldapAuth, err := ldapauth.New(ldapauth.Config{ + URIs: cfg.Server.LDAP.URIs, + SearchBase: cfg.Server.LDAP.SearchBase, + UserAttr: cfg.Server.LDAP.UserAttr, + UserObjectClass: cfg.Server.LDAP.UserObjectClass, + BindDN: cfg.Server.LDAP.BindDN, + BindPassword: cfg.Server.LDAP.BindPassword, + StartTLS: cfg.Server.LDAP.StartTLS, + CACertFile: cfg.Server.LDAP.CACert, + InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify, + }) + if err != nil { + log.Error("ldap auth misconfigured", "err", err) + os.Exit(1) + } + basicAuthFn = ldapAuth.Authenticate + basicAuthRealm = "uopi" + log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase) + } + if basicAuthFn != nil && !cfg.Server.TLS.Enabled { + log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network") + } + + // Built-in TLS (optional): terminate HTTPS directly, recommended whenever + // Basic auth is enabled. + var tlsCert, tlsKey string + if cfg.Server.TLS.Enabled { + if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" { + log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)") + os.Exit(1) + } + tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key + log.Info("built-in TLS enabled", "cert", tlsCert) + } + + srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.UI.DefaultZoom, log) if err := srv.Start(ctx); err != nil { fmt.Fprintf(os.Stderr, "server error: %v\n", err) diff --git a/docs/TECHNICAL_SPEC.md b/docs/TECHNICAL_SPEC.md index 7df2766..f6e1088 100644 --- a/docs/TECHNICAL_SPEC.md +++ b/docs/TECHNICAL_SPEC.md @@ -384,7 +384,39 @@ instance's `setId` in its `Meta`. Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user identity is read per request from `server.trusted_user_header` (with a `default_user` -fallback) and stored on the request context. Access is **role-based** through group +fallback) and stored on the request context. Alternatively, **native SPNEGO/Kerberos** +authentication (`[server.kerberos]`) lets uopi identify users directly from their Kerberos +ticket without a reverse proxy: `internal/server/kerberos.go` wraps the REST and WebSocket +handlers, challenges API requests with `401 WWW-Authenticate: Negotiate`, validates the +ticket against the configured service keytab (`github.com/jcmturner/gokrb5`), and writes the +short principal name (realm stripped) into the same `userHeader` the access pipeline reads +— so downstream identity resolution is identical. Any client-supplied header value is +discarded before validation to prevent spoofing. WebSocket upgrades (where browsers cannot +attach an `Authorization` header) validate proactively-sent credentials best-effort and +otherwise fall back to `default_user`. Browsers must be configured to perform SPNEGO for the +server origin (Firefox: `network.negotiate-auth.trusted-uris`), which fixes the case where +Firefox would otherwise resolve to `default_user`. A third standalone option is **built-in +HTTP Basic authentication** (`[server.basic_auth]`, mutually exclusive with Kerberos): +`internal/server/basicauth.go` challenges with `401 WWW-Authenticate: Basic` and validates +credentials against the host PAM stack (`internal/pamauth`, `/etc/pam.d/`), so +on an SSSD/LDAP-joined host users authenticate with their normal login password and no +directory schema is needed in uopi. Successful logins are memoised in a short-TTL salted-hash +cache (`credCache`) to avoid a PAM round-trip per request, and the validated username is +written into the same `userHeader`. PAM requires a cgo build (`make backend-pam`, build tag +`pam`); the default static binary uses a stub that refuses Basic auth. As a **pure-Go** +alternative that keeps the fully-static (`CGO_ENABLED=0`) binary, `[server.ldap]` +(`internal/ldapauth`) validates the same Basic credentials against an LDAP directory with a +"search then bind" — anonymously (or via a service `bind_dn`) locate the user entry under +`search_base`, then bind as its DN with the supplied password — mirroring an SSSD/LDAP +client (defaults: `user_attr=uid`, `user_object_class=posixAccount`). Empty passwords are +rejected before any bind to avoid LDAP "unauthenticated bind", and the login name is +filter-escaped against injection. The challenge front-end is shared: both PAM and LDAP feed +the same `basicAuth` middleware, and the page load (`/`) is challenged too so the browser's +native login dialog actually appears (a background `fetch('/me')` 401 does not prompt). +Because Basic credentials are sent on every request, uopi can terminate **TLS** itself +(`[server.tls]` → `ListenAndServeTLS`) without a reverse proxy. The four identity sources +(proxy header, Kerberos, PAM-Basic, LDAP-Basic) all resolve to the same `userHeader` and are +mutually exclusive where they overlap. Access is **role-based** through group memberships: each `[[groups]]` block lists members by role along the cumulative ladder `viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global capability is the highest role across all their memberships. Roles map to capabilities as: @@ -425,6 +457,39 @@ opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-gr assignment with an effective-role badge), Groups (nesting + per-member roles), and Server-stats tabs. +#### Visibility scope (selector-tree filtering) + +Every user-owned, list-able object — panels, synthetic signals, config sets/instances, and +control-logic graphs — carries a uniform **visibility scope** so each selector tree can be +filtered by **Mine / Group / Global**. The model is `owner` (stamped server-side from the +trusted identity on create, immutable across updates) plus a scope token +`∈ {private, group, global}` and, for group scope, a list of group names. An empty or +unknown token resolves to **global**, so legacy objects with no scope stay visible to +everyone. This is a *visibility filter*, not a hard security boundary: an owner always sees +their own objects regardless of scope, and the per-panel ACL (§3.10) remains the real +access-control mechanism for panels. + +The shared backend helper is `access.CanSee(user, owner, scope, itemGroups, userGroups)` +(`internal/access/scope.go`), with a `(*Policy).CanSee` method that resolves the caller's +groups via `GroupsOf`. Each list endpoint filters its results through it: +`internal/confmgr` stores `owner/scope/groups` on sets and instances (filtered by +`filterConfigMetas`); synthetic `SignalDef` gained a `group` visibility mode routed through +`synVisible`; control-logic `Graph` carries `owner/scope/scopeGroups` (named to avoid the +pre-existing cosmetic `Groups []NodeGroup`) filtered in `listControlLogic`. Panels reuse the +existing ACL rather than a new field: `panelScope` (`internal/api/api.go`) derives the +bucket from the ACL record (public → global, a group grant → group, otherwise → private; +unmanaged → global) and surfaces it on `InterfaceListItem.scope`/`groups`. + +The shared frontend lib is `web/src/lib/scope.tsx`: `bucketOf` assigns an item to exactly +one bucket (owned → mine, else group-scoped → group, else global), `filterByScope` narrows a +list to the active bucket (with an optional groups-accessor for control-logic's +`scopeGroups`), `ScopeFilter` is the segmented `[Mine | Group ▾ | Global]` selector shown +above each tree (the Group segment carries a combo to pick a group when the user is in +several), and `ScopePicker` is the create/save visibility editor. `ConfigManager.tsx`, +`SyntheticGraphEditor.tsx`, and `ControlLogicEditor.tsx` use the picker to set scope on save; +panel visibility is instead edited through the existing Share dialog. `InterfaceList.tsx` +applies the filter to its folder tree, hiding folders that have no in-scope descendant. + ### 3.11 Configuration Manager The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed @@ -534,6 +599,7 @@ Both edit and view modes support mouse-drag panel resizing: - `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%. - Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font. - **ZoomControl** (A− / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load. +- The root font-size also folds in `window.devicePixelRatio` (`applyZoom` → `16 × zoom × dpr`) so high-DPI displays auto-scale the UI by default; Firefox in particular does not enlarge the root px on its own, so the DPR must be applied explicitly. `watchDpr()` re-applies the zoom when the ratio changes (e.g. the window moves to a differently-scaled monitor). - Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly. ### 4.8 Lua Editor (`LuaEditor.tsx`) @@ -622,6 +688,36 @@ storage_dir = "./interfaces" trusted_user_header = "" # header carrying the proxy-authenticated user default_user = "" # identity when the header is absent (LAN/dev) +# Native SPNEGO/Kerberos auth — alternative to a proxy; identifies users from +# their Kerberos ticket. Recommended when some browsers (e.g. Firefox) would +# otherwise fall through to default_user. +# [server.kerberos] +# enabled = true +# keytab = "/etc/uopi/http.keytab" # HTTP/host@REALM service key +# service_principal = "HTTP/host.example.com" # optional; empty = keytab default + +# Built-in HTTP Basic auth (PAM) — standalone, mutually exclusive with Kerberos. +# Requires a PAM build: `make backend-pam`. Enable TLS below for production. +# [server.basic_auth] +# enabled = true +# pam_service = "uopi" # /etc/pam.d/; empty = "uopi" + +# Built-in HTTP Basic auth (LDAP) — pure-Go, works in the static binary; search +# then bind against the directory. Mutually exclusive with kerberos/basic_auth. +# [server.ldap] +# enabled = true +# uri = ["ldaps://ldap.example.com"] +# search_base = "dc=example,dc=com" +# user_attr = "uid" # "sAMAccountName" for AD; empty = uid +# bind_dn = "" # empty = anonymous search + +# Built-in TLS/HTTPS — terminate HTTPS without a reverse proxy. Recommended +# whenever basic_auth is enabled. Both cert and key required when enabled. +# [server.tls] +# enabled = true +# cert = "/etc/uopi/tls/cert.pem" +# key = "/etc/uopi/tls/key.pem" + # Role-based access through group memberships. Roles (low→high): # viewer < operator < logiceditor < auditor < admin # Effective capability = highest role across all memberships. The built-in diff --git a/go.mod b/go.mod index 960acd2..f62c0bf 100644 --- a/go.mod +++ b/go.mod @@ -11,11 +11,18 @@ require ( ) require ( + github.com/Azure/go-ntlmssp v0.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-ldap/ldap/v3 v3.4.13 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.66.10 // indirect diff --git a/go.sum b/go.sum index bc560b9..a967b51 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,97 @@ cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= +github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= +github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo= github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= +github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA= github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/internal/access/scope.go b/internal/access/scope.go new file mode 100644 index 0000000..4d9275d --- /dev/null +++ b/internal/access/scope.go @@ -0,0 +1,55 @@ +package access + +import ( + "slices" + "strings" +) + +// Visibility scope tokens shared by user-owned, filterable objects across uopi +// (config sets/instances, control-logic graphs, synthetic signals). They drive +// the per-tree "Mine / Group / Global" selector. Storage carries the scope as a +// plain string so each subsystem can embed it in its own JSON without depending +// on this package's types. +const ( + // ScopePrivate: visible only to the owner. + ScopePrivate = "private" + // ScopeGroup: visible to the owner and members of any listed group. + ScopeGroup = "group" + // ScopeGlobal: visible to everyone. This is also the legacy default — an + // empty/unknown scope is treated as global so objects created before scopes + // existed stay visible to all. + ScopeGlobal = "global" +) + +// CanSee reports whether user (a member of userGroups) may see an object with the +// given owner, scope and itemGroups. An empty or unrecognised scope is treated as +// global, so legacy objects without a scope remain visible to everyone. +// +// This is a visibility filter for selector trees, not a hard security boundary: +// it governs which objects are offered in listings, and intentionally always +// shows an object to its owner regardless of scope. +func CanSee(user, owner, scope string, itemGroups, userGroups []string) bool { + switch strings.ToLower(strings.TrimSpace(scope)) { + case ScopePrivate: + return owner != "" && owner == user + case ScopeGroup: + if owner != "" && owner == user { + return true + } + for _, g := range itemGroups { + if slices.Contains(userGroups, g) { + return true + } + } + return false + default: // global / empty / unknown + return true + } +} + +// CanSee reports whether user may see an object with the given owner, scope and +// itemGroups, resolving the user's group memberships from the policy. It is the +// convenience wrapper list handlers use. +func (p *Policy) CanSee(user, owner, scope string, itemGroups []string) bool { + return CanSee(user, owner, scope, itemGroups, p.GroupsOf(user)) +} diff --git a/internal/access/scope_test.go b/internal/access/scope_test.go new file mode 100644 index 0000000..c6a7699 --- /dev/null +++ b/internal/access/scope_test.go @@ -0,0 +1,47 @@ +package access + +import "testing" + +func TestCanSee(t *testing.T) { + cases := []struct { + name string + user string + owner string + scope string + itemGroups []string + userGroups []string + want bool + }{ + {"global visible to anyone", "bob", "alice", ScopeGlobal, nil, nil, true}, + {"empty scope = global", "bob", "alice", "", nil, nil, true}, + {"unknown scope = global", "bob", "alice", "weird", nil, nil, true}, + {"private hidden from others", "bob", "alice", ScopePrivate, nil, nil, false}, + {"private visible to owner", "alice", "alice", ScopePrivate, nil, nil, true}, + {"private with empty owner hidden", "bob", "", ScopePrivate, nil, nil, false}, + {"group visible to member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"ops"}, true}, + {"group hidden from non-member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"eng"}, false}, + {"group visible to owner even if not a member", "alice", "alice", ScopeGroup, []string{"ops"}, nil, true}, + {"group with multiple item groups", "bob", "alice", ScopeGroup, []string{"ops", "eng"}, []string{"eng"}, true}, + {"case-insensitive scope token", "bob", "alice", "PRIVATE", nil, nil, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := CanSee(c.user, c.owner, c.scope, c.itemGroups, c.userGroups); got != c.want { + t.Errorf("CanSee(%q,%q,%q,%v,%v) = %v, want %v", + c.user, c.owner, c.scope, c.itemGroups, c.userGroups, got, c.want) + } + }) + } +} + +func TestPolicyCanSeeResolvesGroups(t *testing.T) { + p := New("", []GroupSpec{{Name: "ops", Members: map[string]Role{"bob": RoleOperator}}}) + // bob is a member of ops; a group-scoped item shared with ops is visible. + if !p.CanSee("bob", "alice", ScopeGroup, []string{"ops"}) { + t.Errorf("expected bob to see an ops-scoped item") + } + // carol is in no group; the same item is hidden. + if p.CanSee("carol", "alice", ScopeGroup, []string{"ops"}) { + t.Errorf("expected carol not to see an ops-scoped item") + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 65902bf..8504d05 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -44,12 +44,13 @@ type Handler struct { audit audit.Recorder // never nil; audit.Nop when disabled channelFinderURL string // empty if not configured archiverURL string // empty if not configured + uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0 log *slog.Logger } // New creates an API Handler. synth may be nil if the synthetic DS is disabled. // rec records system-affecting mutations; pass audit.Nop() to disable auditing. -func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler { +func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler { if rec == nil { rec = audit.Nop() } @@ -65,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg audit: rec, channelFinderURL: channelFinderURL, archiverURL: archiverURL, + uiDefaultZoom: uiDefaultZoom, log: log, } } @@ -192,6 +194,10 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) { if groups == nil { groups = []string{} } + defaultZoom := h.uiDefaultZoom + if defaultZoom <= 0 { + defaultZoom = 1.0 + } jsonOK(w, map[string]any{ "user": user, "level": h.policy.Level(user).String(), @@ -199,6 +205,7 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) { "canEditLogic": h.policy.CanEditLogic(user), "canViewAudit": h.policy.CanViewAudit(user), "canAdmin": h.policy.CanAdmin(user), + "defaultZoom": defaultZoom, }) } @@ -366,8 +373,9 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { if dsName == "synthetic" && h.synthetic != nil { user := caller(r) panel := r.URL.Query().Get("panel") + userGroups := h.policy.GroupsOf(user) metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool { - return synVisible(d, user, panel) + return synVisible(d, user, panel, userGroups) }) out := make([]signalInfo, len(metas)) for i, m := range metas { @@ -398,12 +406,15 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) { } // synVisible reports whether a synthetic signal should be listed for the given -// caller while editing the given panel. An empty Visibility is treated as -// "global" so legacy definitions remain visible everywhere. -func synVisible(d synthetic.SignalDef, user, panel string) bool { +// caller (a member of userGroups) while editing the given panel. An empty +// Visibility is treated as "global" so legacy definitions remain visible +// everywhere. +func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool { switch d.Visibility { case "user": return user != "" && d.Owner == user + case "group": + return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups) case "panel": return panel != "" && d.Panel == panel default: // "global" or legacy empty @@ -431,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) { user := caller(r) panel := r.URL.Query().Get("panel") + userGroups := h.policy.GroupsOf(user) var out []signalInfo for _, ds := range sources { var metas []datasource.Metadata if ds.Name() == "synthetic" && h.synthetic != nil { metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool { - return synVisible(d, user, panel) + return synVisible(d, user, panel, userGroups) }) } else { var err error @@ -593,10 +605,33 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) { // render sharing affordances and filter the visible set. type interfaceListItem struct { storage.InterfaceMeta - Owner string `json:"owner,omitempty"` - Folder string `json:"folder,omitempty"` - Order float64 `json:"order,omitempty"` - Perm string `json:"perm"` + Owner string `json:"owner,omitempty"` + Folder string `json:"folder,omitempty"` + Order float64 `json:"order,omitempty"` + Perm string `json:"perm"` + Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global + Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with +} + +// panelScope derives a uniform visibility token (and, for group scope, the +// shared group names) from a panel's ACL record so the selector tree can bucket +// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel +// (nil record) or any panel exposed publicly is global; otherwise a panel shared +// with one or more user-groups is group-scoped; everything else is private. +func panelScope(acl *panelacl.PanelACL) (string, []string) { + if acl == nil || acl.Public != "" { + return access.ScopeGlobal, nil + } + var groups []string + for _, g := range acl.Grants { + if g.Kind == "group" { + groups = append(groups, g.Name) + } + } + if len(groups) > 0 { + return access.ScopeGroup, groups + } + return access.ScopePrivate, nil } func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { @@ -613,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) { continue // hide panels the caller cannot see } item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()} - if acl := h.acl.GetPanel(m.ID); acl != nil { + acl := h.acl.GetPanel(m.ID) + if acl != nil { item.Owner = acl.Owner item.Folder = acl.Folder item.Order = acl.Order } + item.Scope, item.Groups = panelScope(acl) out = append(out, item) } h.log.Info("list interfaces", "count", len(out)) @@ -1548,14 +1585,17 @@ func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) { // ── Control logic ────────────────────────────────────────────────────────────── -func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) { +func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) { if h.ctrlLogic == nil { jsonOK(w, []any{}) return } - graphs := h.ctrlLogic.List() - if graphs == nil { - graphs = []controllogic.Graph{} + user := caller(r) + graphs := []controllogic.Graph{} + for _, g := range h.ctrlLogic.List() { + if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) { + graphs = append(graphs, g) + } } jsonOK(w, graphs) } @@ -1588,6 +1628,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) { return } g.ID = genID("cl") + g.Owner = caller(r) // stamp ownership from the trusted identity if err := h.ctrlLogic.Save(g); err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return @@ -1608,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) { return } id := r.PathValue("id") - if _, err := h.ctrlLogic.Get(id); err != nil { + prev, err := h.ctrlLogic.Get(id) + if err != nil { jsonError(w, http.StatusNotFound, "control logic graph not found: "+id) return } @@ -1618,6 +1660,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) { return } g.ID = id + g.Owner = prev.Owner // owner is immutable across revisions if err := h.ctrlLogic.Save(g); err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 63a628b..04c4f63 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) { clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log) mux := http.NewServeMux() - api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1") + api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1") srv := httptest.NewServer(mux) return srv, func() { @@ -485,7 +485,7 @@ func adminSetup(t *testing.T) (*httptest.Server, func()) { } inner := http.NewServeMux() - api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", log).Register(inner, "/api/v1") + api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1") mux := http.NewServeMux() mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/confmgr.go b/internal/api/confmgr.go index f82027c..db35674 100644 --- a/internal/api/confmgr.go +++ b/internal/api/confmgr.go @@ -37,7 +37,7 @@ func configStatus(err error) int { // ── config sets ───────────────────────────────────────────────────────────── -func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) { +func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) { if !h.configEnabled(w) { return } @@ -46,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) { jsonError(w, http.StatusInternalServerError, err.Error()) return } - jsonOK(w, sets) + jsonOK(w, h.filterConfigMetas(r, sets)) } func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) { @@ -92,6 +92,9 @@ func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } + if prev, err := h.cfg.GetSet(id); err == nil { + set.Owner = prev.Owner // owner is immutable across revisions + } out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag")) if err != nil { jsonError(w, configStatus(err), err.Error()) @@ -227,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) { // ── config instances ──────────────────────────────────────────────────────── -func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) { +func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) { if !h.configEnabled(w) { return } @@ -236,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) { jsonError(w, http.StatusInternalServerError, err.Error()) return } - jsonOK(w, insts) + jsonOK(w, h.filterConfigMetas(r, insts)) +} + +// filterConfigMetas drops entries the caller may not see per their scope +// (private/group/global). Owner always sees their own; empty scope = global. +func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta { + user := caller(r) + out := make([]confmgr.Meta, 0, len(metas)) + for _, m := range metas { + if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) { + out = append(out, m) + } + } + return out } func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) { @@ -282,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) { jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } + if prev, err := h.cfg.GetInstance(id); err == nil { + inst.Owner = prev.Owner // owner is immutable across revisions + } out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag")) if err != nil { jsonError(w, configStatus(err), err.Error()) diff --git a/internal/config/config.go b/internal/config/config.go index 80c492a..cf68fec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,10 +13,22 @@ type Config struct { Server ServerConfig `toml:"server"` Datasource DatasourceConfig `toml:"datasource"` Audit AuditConfig `toml:"audit"` + UI UIConfig `toml:"ui"` // Groups are named sets of users, referenced by panel sharing rules. Groups []GroupDef `toml:"groups"` } +// UIConfig carries client-side presentation defaults sent to the browser at +// startup (via /api/v1/me). +type UIConfig struct { + // DefaultZoom is the base UI scale multiplier applied when a browser has no + // per-machine zoom override saved. Useful to enlarge the UI by default on + // HiDPI screens whose OS scaling is left at 100% (where the browser reports + // devicePixelRatio=1 and the UI would otherwise render small). The in-app + // A+/A− control still overrides it locally. 0 or unset means 1.0 (no scaling). + DefaultZoom float64 `toml:"default_zoom"` +} + // AuditConfig controls the audit trail. When enabled, every user and automated // action that could affect the controlled system (signal writes, control-logic // changes) is recorded to a SQLite database for later review by audit staff. Who @@ -65,6 +77,116 @@ type ServerConfig struct { // {storage_dir}/access.json, that file — not this config — is the source of // truth for access. DefaultUser string `toml:"default_user"` + + // Kerberos enables native SPNEGO authentication (see KerberosConfig). + Kerberos KerberosConfig `toml:"kerberos"` + + // BasicAuth enables built-in HTTP Basic authentication validated against PAM + // (see BasicAuthConfig). Mutually exclusive with Kerberos. + BasicAuth BasicAuthConfig `toml:"basic_auth"` + + // LDAP enables built-in HTTP Basic authentication validated against an LDAP + // directory (see LDAPConfig). Pure-Go alternative to BasicAuth/PAM that keeps + // the static binary. Mutually exclusive with Kerberos and BasicAuth. + LDAP LDAPConfig `toml:"ldap"` + + // TLS enables built-in HTTPS (see TLSConfig). Strongly recommended whenever + // BasicAuth is enabled, since Basic credentials are sent on every request. + TLS TLSConfig `toml:"tls"` +} + +// KerberosConfig enables native SPNEGO/Kerberos ("Negotiate") authentication so +// uopi identifies users directly from their Kerberos ticket, without depending on +// a separate auth proxy to set TrustedUserHeader. This is the recommended setup +// for browsers like Firefox that do not silently fall back to a proxy default: +// uopi answers API requests with a 401 WWW-Authenticate: Negotiate challenge, the +// browser performs the SPNEGO handshake, and uopi resolves the user from the +// validated ticket (short principal name, realm stripped). That username feeds +// the same access pipeline as TrustedUserHeader. +// +// Browsers must be told to perform SPNEGO for this server's origin (Firefox: +// network.negotiate-auth.trusted-uris; Chrome/Edge: AuthServerAllowlist policy or +// OS integrated auth). When enabled, any inbound TrustedUserHeader value is +// ignored in favour of the Kerberos identity to prevent spoofing. +type KerberosConfig struct { + Enabled bool `toml:"enabled"` + // Keytab is the path to the service keytab holding the HTTP service + // principal's long-term key (e.g. HTTP/host.example.com@REALM). Required when + // Enabled. + Keytab string `toml:"keytab"` + // ServicePrincipal optionally selects which principal in the keytab to accept + // tickets for (e.g. "HTTP/host.example.com"). Empty accepts the keytab's + // entries by default. + ServicePrincipal string `toml:"service_principal"` +} + +// BasicAuthConfig enables uopi's built-in HTTP Basic authentication: uopi +// answers API requests with 401 WWW-Authenticate: Basic, the browser prompts for +// a username/password, and uopi validates them through the host PAM stack +// (/etc/pam.d/). On hosts that are SSSD/LDAP clients this reuses the +// users' normal login credentials with no directory configuration in uopi. The +// validated username feeds the same access pipeline as TrustedUserHeader. +// +// PAM support requires a cgo build with the `pam` tag (make backend-pam); the +// default fully-static binary cannot validate and will refuse to start with +// BasicAuth enabled. Because Basic credentials travel on every request, enable +// TLS (see TLSConfig) unless uopi sits on a fully isolated network. +type BasicAuthConfig struct { + Enabled bool `toml:"enabled"` + // PAMService is the PAM service name under /etc/pam.d/ to authenticate + // against. Empty defaults to "uopi". + PAMService string `toml:"pam_service"` +} + +// LDAPConfig enables uopi's built-in HTTP Basic authentication validated against +// an LDAP directory via the "search then bind" pattern — the same flow an +// SSSD/LDAP client uses. Because it speaks LDAP over the wire with no cgo, it +// works in the default fully-static binary (unlike the PAM backend), while still +// authenticating users against the same directory the host logs in with. The +// validated username feeds the same access pipeline as TrustedUserHeader. Enable +// TLS (ldaps:// or StartTLS) so passwords are not sent in clear text. +// +// Defaults mirror SSSD: empty UserAttr → "uid", empty UserObjectClass → +// "posixAccount", empty BindDN → anonymous search. Mutually exclusive with +// Kerberos and BasicAuth. +type LDAPConfig struct { + Enabled bool `toml:"enabled"` + // URIs are the directory endpoints (SSSD ldap_uri), tried in order, e.g. + // "ldaps://ldap.example.com". Required when Enabled. + URIs []string `toml:"uri"` + // SearchBase is the subtree user entries live under (SSSD ldap_search_base). + // Required when Enabled. + SearchBase string `toml:"search_base"` + // UserAttr is the attribute matched against the login name. Empty → "uid". + UserAttr string `toml:"user_attr"` + // UserObjectClass restricts the search. Empty → "posixAccount". + UserObjectClass string `toml:"user_object_class"` + // BindDN / BindPassword optionally authenticate the search (service account). + // Empty BindDN performs an anonymous search. + BindDN string `toml:"bind_dn"` + BindPassword string `toml:"bind_password"` + // StartTLS upgrades an ldap:// connection to TLS before binding. Ignored for + // ldaps://. + StartTLS bool `toml:"start_tls"` + // CACert is an optional PEM CA bundle to trust (private CA). + CACert string `toml:"ca_cert"` + // InsecureSkipVerify disables TLS certificate verification. Testing only. + InsecureSkipVerify bool `toml:"insecure_skip_verify"` +} + +// TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for +// Basic auth) without a reverse proxy. When Enabled, Cert and Key are required. +type TLSConfig struct { + Enabled bool `toml:"enabled"` + // Cert and Key are paths to the PEM certificate and private key. + Cert string `toml:"cert"` + Key string `toml:"key"` + // RedirectFrom, when set (e.g. ":8080"), starts an additional plain-HTTP + // listener on that address that 301-redirects every request to the HTTPS + // service. Without it, a browser that connects with http:// gets the opaque + // "client sent an HTTP request to an HTTPS server" error instead of being + // upgraded. Empty disables the redirector. + RedirectFrom string `toml:"redirect_from"` } type DatasourceConfig struct { @@ -146,6 +268,45 @@ func applyEnv(cfg *Config) { if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" { cfg.Server.DefaultUser = v } + if v := env("UOPI_SERVER_KERBEROS_ENABLED"); v != "" { + cfg.Server.Kerberos.Enabled = (v == "true" || v == "YES") + } + if v := env("UOPI_SERVER_KERBEROS_KEYTAB"); v != "" { + cfg.Server.Kerberos.Keytab = v + } + if v := env("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL"); v != "" { + cfg.Server.Kerberos.ServicePrincipal = v + } + if v := env("UOPI_SERVER_BASIC_AUTH_ENABLED"); v != "" { + cfg.Server.BasicAuth.Enabled = (v == "true" || v == "YES") + } + if v := env("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE"); v != "" { + cfg.Server.BasicAuth.PAMService = v + } + if v := env("UOPI_SERVER_TLS_ENABLED"); v != "" { + cfg.Server.TLS.Enabled = (v == "true" || v == "YES") + } + if v := env("UOPI_SERVER_TLS_CERT"); v != "" { + cfg.Server.TLS.Cert = v + } + if v := env("UOPI_SERVER_TLS_KEY"); v != "" { + cfg.Server.TLS.Key = v + } + if v := env("UOPI_SERVER_LDAP_ENABLED"); v != "" { + cfg.Server.LDAP.Enabled = (v == "true" || v == "YES") + } + if v := env("UOPI_SERVER_LDAP_URI"); v != "" { + cfg.Server.LDAP.URIs = strings.Fields(v) + } + if v := env("UOPI_SERVER_LDAP_SEARCH_BASE"); v != "" { + cfg.Server.LDAP.SearchBase = v + } + if v := env("UOPI_SERVER_LDAP_BIND_DN"); v != "" { + cfg.Server.LDAP.BindDN = v + } + if v := env("UOPI_SERVER_LDAP_BIND_PASSWORD"); v != "" { + cfg.Server.LDAP.BindPassword = v + } if v := env("UOPI_AUDIT_ENABLED"); v != "" { cfg.Audit.Enabled = (v == "true" || v == "YES") } @@ -170,6 +331,11 @@ func applyEnv(cfg *Config) { if v := env("EPICS_PVA_ADDR_LIST"); v != "" { cfg.Datasource.PVA.AddrList = strings.Fields(v) } + if v := env("UOPI_UI_DEFAULT_ZOOM"); v != "" { + if z, err := strconv.ParseFloat(v, 64); err == nil { + cfg.UI.DefaultZoom = z + } + } } func env(key string) string { diff --git a/internal/confmgr/model.go b/internal/confmgr/model.go index f66ec12..7ce1a32 100644 --- a/internal/confmgr/model.go +++ b/internal/confmgr/model.go @@ -61,6 +61,8 @@ type ConfigSet struct { Version int `json:"version"` Tag string `json:"tag,omitempty"` Owner string `json:"owner,omitempty"` + Scope string `json:"scope,omitempty"` // access.Scope* visibility token + Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility Parameters []Parameter `json:"parameters"` } @@ -75,6 +77,8 @@ type ConfigInstance struct { Version int `json:"version"` Tag string `json:"tag,omitempty"` Owner string `json:"owner,omitempty"` + Scope string `json:"scope,omitempty"` // access.Scope* visibility token + Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility Values map[string]any `json:"values"` } diff --git a/internal/confmgr/store.go b/internal/confmgr/store.go index 6f790b2..3b59d56 100644 --- a/internal/confmgr/store.go +++ b/internal/confmgr/store.go @@ -49,6 +49,11 @@ type Meta struct { // rules without the flag. Lets the rule list show enabled/disabled status // without a GET per rule. Enabled *bool `json:"enabled,omitempty"` + // Owner/Scope/Groups carry the visibility metadata so list handlers can filter + // by the caller without a GET per object. Empty scope = global (legacy-safe). + Owner string `json:"owner,omitempty"` + Scope string `json:"scope,omitempty"` + Groups []string `json:"groups,omitempty"` } // VersionMeta describes a single persisted revision. @@ -87,9 +92,12 @@ type header struct { ID string `json:"id"` Name string `json:"name"` Version int `json:"version"` - Tag string `json:"tag"` - SetID string `json:"setId"` - Enabled *bool `json:"enabled"` + Tag string `json:"tag"` + SetID string `json:"setId"` + Enabled *bool `json:"enabled"` + Owner string `json:"owner"` + Scope string `json:"scope"` + Groups []string `json:"groups"` } func validateID(id string) error { @@ -154,7 +162,7 @@ func (s *Store) List(k Kind) ([]Meta, error) { if err != nil { continue } - out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled}) + out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled, Owner: h.Owner, Scope: h.Scope, Groups: h.Groups}) } return out, nil } diff --git a/internal/controllogic/model.go b/internal/controllogic/model.go index 0b21854..ce3ea30 100644 --- a/internal/controllogic/model.go +++ b/internal/controllogic/model.go @@ -66,6 +66,9 @@ type Graph struct { Enabled bool `json:"enabled"` Version int `json:"version,omitempty"` // git-style revision; bumped on each Save Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3") + Owner string `json:"owner,omitempty"` // creator identity (stamped server-side) + Scope string `json:"scope,omitempty"` // access.Scope* visibility token + ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility Nodes []Node `json:"nodes"` Wires []Wire `json:"wires"` Groups []NodeGroup `json:"groups,omitempty"` diff --git a/internal/datasource/synthetic/definition.go b/internal/datasource/synthetic/definition.go index afa5019..b3d5d04 100644 --- a/internal/datasource/synthetic/definition.go +++ b/internal/datasource/synthetic/definition.go @@ -15,12 +15,14 @@ type SignalDef struct { // Visibility controls who sees this signal in the signal tree: // "global" — listed in every panel's edit mode // "user" — listed in every panel owned by Owner + // "group" — listed for Owner and members of any group in Groups // "panel" — listed only when editing the bound Panel // An empty value is treated as "global" for backward compatibility with // definitions created before this field existed. - Visibility string `json:"visibility,omitempty"` - Owner string `json:"owner,omitempty"` // creator identity (stamped server-side) - Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility + Visibility string `json:"visibility,omitempty"` + Owner string `json:"owner,omitempty"` // creator identity (stamped server-side) + Groups []string `json:"groups,omitempty"` // groups for "group" visibility + Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility // Version and Tag implement git-style revisioning. Version is bumped on // every UpdateSignal; superseded revisions are kept as backup files. Tag is diff --git a/internal/ldapauth/ldapauth.go b/internal/ldapauth/ldapauth.go new file mode 100644 index 0000000..8e431cf --- /dev/null +++ b/internal/ldapauth/ldapauth.go @@ -0,0 +1,195 @@ +// Package ldapauth validates a username/password pair against an LDAP directory +// using the standard "search then bind" pattern, exactly as an SSSD/LDAP client +// would: connect to the directory, find the user's entry under the configured +// search base, then attempt a bind as that entry's DN with the supplied password. +// +// It is a pure-Go alternative to the PAM backend (internal/pamauth): because it +// speaks LDAP over the wire with no cgo, uopi keeps its fully-static +// (CGO_ENABLED=0) release binary while still authenticating against the same +// directory the host logs in with. It feeds the same HTTP Basic pipeline +// (internal/server/basicauth.go) as PAM. +// +// Unlike PAM it only verifies the password; it does not run the rest of the PAM +// stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally +// sufficient. +package ldapauth + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" + "time" + + "github.com/go-ldap/ldap/v3" +) + +// Config configures the LDAP authenticator. URIs and SearchBase are required; the +// remaining fields mirror SSSD defaults so an unconfigured directory (anonymous +// search, RFC2307 schema) works out of the box. +type Config struct { + // URIs are the directory endpoints, tried in order until one connects, e.g. + // "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's + // ldap_uri. + URIs []string + // SearchBase is the subtree under which user entries are searched. Mirrors + // SSSD's ldap_search_base. + SearchBase string + // UserAttr is the attribute matched against the login name. Empty defaults to + // "uid" (SSSD's ldap_user_name default for RFC2307). + UserAttr string + // UserObjectClass restricts the search to this objectClass. Empty defaults to + // "posixAccount" (SSSD's ldap_user_object_class default). + UserObjectClass string + // BindDN / BindPassword optionally authenticate the *search* (service + // account). Empty BindDN performs an anonymous search, matching a directory + // configured without ldap_default_bind_dn. + BindDN string + BindPassword string + // StartTLS upgrades a plain ldap:// connection to TLS before any credentials + // are sent. Ignored for ldaps:// (already TLS). + StartTLS bool + // CACertFile is an optional PEM file of CA certs to trust for the TLS + // connection (for a directory using a private CA). + CACertFile string + // InsecureSkipVerify disables TLS certificate verification. Insecure; use only + // for testing against a self-signed directory. + InsecureSkipVerify bool + // Timeout bounds each connection attempt. Zero defaults to 10s. + Timeout time.Duration +} + +// ErrInvalidCredentials is returned when the directory rejects the user's bind. +var ErrInvalidCredentials = errors.New("ldap: invalid credentials") + +// Authenticator validates credentials against a fixed directory configuration. +// It is safe for concurrent use: each Authenticate call opens and closes its own +// connection. +type Authenticator struct { + cfg Config + tlsConfig *tls.Config +} + +// New validates cfg and returns an Authenticator. It fails fast on missing +// required fields or an unreadable CA file so misconfiguration surfaces at +// startup rather than on the first login. +func New(cfg Config) (*Authenticator, error) { + if len(cfg.URIs) == 0 { + return nil, errors.New("ldap: at least one uri is required") + } + if cfg.SearchBase == "" { + return nil, errors.New("ldap: search_base is required") + } + if cfg.UserAttr == "" { + cfg.UserAttr = "uid" + } + if cfg.UserObjectClass == "" { + cfg.UserObjectClass = "posixAccount" + } + if cfg.Timeout <= 0 { + cfg.Timeout = 10 * time.Second + } + + tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify} + if cfg.CACertFile != "" { + pem, err := os.ReadFile(cfg.CACertFile) + if err != nil { + return nil, fmt.Errorf("ldap: reading ca_cert: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile) + } + tlsConfig.RootCAs = pool + } + + return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil +} + +// Authenticate verifies username/password against the directory. It returns nil +// on success, ErrInvalidCredentials when the directory rejects the bind, or +// another error on connection/search failure. +func (a *Authenticator) Authenticate(username, password string) error { + // A bind with a non-empty DN but an empty password is an "unauthenticated + // bind" that many servers accept as success — which would let anyone in with a + // blank password. Reject empty passwords before we ever bind. + if password == "" { + return ErrInvalidCredentials + } + + conn, err := a.dial() + if err != nil { + return err + } + defer conn.Close() + + // Bind for the search: service account if configured, else anonymous. + if a.cfg.BindDN != "" { + if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil { + return fmt.Errorf("ldap: search bind failed: %w", err) + } + } + + // Locate the user's entry. The login name is escaped to prevent LDAP filter + // injection. + filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))", + ldap.EscapeFilter(a.cfg.UserObjectClass), + a.cfg.UserAttr, + ldap.EscapeFilter(username)) + req := ldap.NewSearchRequest( + a.cfg.SearchBase, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, + 2, int(a.cfg.Timeout.Seconds()), false, + filter, + []string{"dn"}, nil, + ) + res, err := conn.Search(req) + if err != nil { + return fmt.Errorf("ldap: search failed: %w", err) + } + if len(res.Entries) == 0 { + return ErrInvalidCredentials // unknown user — do not distinguish from bad password + } + if len(res.Entries) > 1 { + return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries)) + } + userDN := res.Entries[0].DN + + // Verify the password by binding as the user. Use a fresh connection so the + // search identity is fully dropped first. + userConn, err := a.dial() + if err != nil { + return err + } + defer userConn.Close() + if err := userConn.Bind(userDN, password); err != nil { + if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) { + return ErrInvalidCredentials + } + return fmt.Errorf("ldap: user bind failed: %w", err) + } + return nil +} + +// dial connects to the first reachable URI and applies StartTLS when requested. +func (a *Authenticator) dial() (*ldap.Conn, error) { + var lastErr error + for _, uri := range a.cfg.URIs { + conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig)) + if err != nil { + lastErr = err + continue + } + conn.SetTimeout(a.cfg.Timeout) + if a.cfg.StartTLS { + if err := conn.StartTLS(a.tlsConfig); err != nil { + conn.Close() + lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err) + continue + } + } + return conn, nil + } + return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr) +} diff --git a/internal/ldapauth/ldapauth_test.go b/internal/ldapauth/ldapauth_test.go new file mode 100644 index 0000000..f19db5b --- /dev/null +++ b/internal/ldapauth/ldapauth_test.go @@ -0,0 +1,60 @@ +package ldapauth + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestNewRequiresURIAndBase(t *testing.T) { + if _, err := New(Config{SearchBase: "dc=x"}); err == nil { + t.Fatal("want error for missing uri") + } + if _, err := New(Config{URIs: []string{"ldap://x"}}); err == nil { + t.Fatal("want error for missing search_base") + } +} + +func TestNewAppliesSSSDDefaults(t *testing.T) { + a, err := New(Config{URIs: []string{"ldap://x"}, SearchBase: "dc=x"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if a.cfg.UserAttr != "uid" { + t.Errorf("UserAttr default = %q, want uid", a.cfg.UserAttr) + } + if a.cfg.UserObjectClass != "posixAccount" { + t.Errorf("UserObjectClass default = %q, want posixAccount", a.cfg.UserObjectClass) + } + if a.cfg.Timeout <= 0 { + t.Errorf("Timeout default not applied: %v", a.cfg.Timeout) + } +} + +// Empty passwords must be rejected before any bind: a non-empty DN + empty +// password is an "unauthenticated bind" many servers accept as success. +func TestAuthenticateRejectsEmptyPasswordWithoutDialing(t *testing.T) { + // An unreachable URI guarantees the test fails loudly if it ever tries to dial. + a, err := New(Config{URIs: []string{"ldap://127.0.0.1:1"}, SearchBase: "dc=x"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if err := a.Authenticate("alice", ""); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("want ErrInvalidCredentials for empty password, got %v", err) + } +} + +func TestNewRejectsBadCACert(t *testing.T) { + dir := t.TempDir() + bad := filepath.Join(dir, "ca.pem") + if err := os.WriteFile(bad, []byte("not a certificate"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: bad}); err == nil { + t.Fatal("want error for CA file with no certificates") + } + if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: filepath.Join(dir, "missing.pem")}); err == nil { + t.Fatal("want error for missing CA file") + } +} diff --git a/internal/pamauth/pam.go b/internal/pamauth/pam.go new file mode 100644 index 0000000..f83e6ab --- /dev/null +++ b/internal/pamauth/pam.go @@ -0,0 +1,106 @@ +//go:build pam + +// Package pamauth authenticates a username/password pair against the host's PAM +// stack (/etc/pam.d/). It is the backend for uopi's built-in HTTP Basic +// authentication: because the uopi host is typically already an SSSD/LDAP client, +// validating through PAM reuses the exact same login path as `login`/`ssh` +// (pam_sss → the site directory) without uopi needing any directory schema. +// +// This file is the real implementation, compiled only with the `pam` build tag +// (which also requires cgo + libpam). The default fully-static CGO_ENABLED=0 +// build uses stub.go instead, where Authenticate reports PAM is unavailable. +package pamauth + +/* +#cgo LDFLAGS: -lpam +#include +#include +#include + +// uopiPamConv answers every password-style PAM prompt with the password passed +// through appdata_ptr. Informational/error messages get a NULL response. The PAM +// library takes ownership of the returned responses and frees them. +static int uopiPamConv(int num_msg, const struct pam_message **msg, + struct pam_response **resp, void *appdata_ptr) { + if (num_msg <= 0 || num_msg > PAM_MAX_NUM_MSG) { + return PAM_CONV_ERR; + } + struct pam_response *r = calloc((size_t)num_msg, sizeof(struct pam_response)); + if (r == NULL) { + return PAM_BUF_ERR; + } + for (int i = 0; i < num_msg; i++) { + int style = msg[i]->msg_style; + if (style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON) { + r[i].resp = strdup((const char *)appdata_ptr); + if (r[i].resp == NULL) { + for (int j = 0; j < i; j++) { + free(r[j].resp); + } + free(r); + return PAM_BUF_ERR; + } + } + r[i].resp_retcode = 0; + } + *resp = r; + return PAM_SUCCESS; +} + +// uopiPamAuth runs authentication + account management for service/user using +// pass. Returns PAM_SUCCESS or the failing PAM error code. +static int uopiPamAuth(const char *service, const char *user, char *pass) { + struct pam_conv conv; + conv.conv = uopiPamConv; + conv.appdata_ptr = (void *)pass; + + pam_handle_t *pamh = NULL; + int ret = pam_start(service, user, &conv, &pamh); + if (ret != PAM_SUCCESS) { + return ret; + } + ret = pam_authenticate(pamh, PAM_DISALLOW_NULL_AUTHTOK); + if (ret == PAM_SUCCESS) { + ret = pam_acct_mgmt(pamh, PAM_DISALLOW_NULL_AUTHTOK); + } + pam_end(pamh, ret); + return ret; +} + +// uopiStrerror maps a PAM error code to a human-readable string. Linux-PAM +// ignores the handle, so NULL is fine after pam_end. +static const char *uopiStrerror(int code) { + return pam_strerror(NULL, code); +} +*/ +import "C" + +import ( + "errors" + "fmt" + "unsafe" +) + +// Available reports whether this build includes PAM support. True here. +const Available = true + +// ErrUnavailable is returned by Authenticate in builds without PAM support. It +// is declared in both build variants so callers can compare against it. +var ErrUnavailable = errors.New("pamauth: PAM support not compiled in") + +// Authenticate verifies username/password against the named PAM service +// (/etc/pam.d/). It returns nil on success or an error describing the +// PAM failure. It is safe for concurrent use. +func Authenticate(service, username, password string) error { + cService := C.CString(service) + cUser := C.CString(username) + cPass := C.CString(password) + defer C.free(unsafe.Pointer(cService)) + defer C.free(unsafe.Pointer(cUser)) + defer C.free(unsafe.Pointer(cPass)) + + if ret := C.uopiPamAuth(cService, cUser, cPass); ret != C.PAM_SUCCESS { + return fmt.Errorf("pam: %s", C.GoString(C.uopiStrerror(ret))) + } + return nil +} diff --git a/internal/pamauth/stub.go b/internal/pamauth/stub.go new file mode 100644 index 0000000..cccfc3f --- /dev/null +++ b/internal/pamauth/stub.go @@ -0,0 +1,21 @@ +//go:build !pam + +// Package pamauth authenticates a username/password pair against the host's PAM +// stack. This is the stub compiled into the default fully-static +// (CGO_ENABLED=0) build, which has no PAM/libpam linkage: Authenticate always +// reports PAM is unavailable. Build with `make backend-pam` (CGO_ENABLED=1 +// -tags pam) to get the real implementation in pam.go. +package pamauth + +import "errors" + +// Available reports whether this build includes PAM support. False here. +const Available = false + +// ErrUnavailable is returned by Authenticate because this build lacks PAM. +var ErrUnavailable = errors.New("pamauth: PAM support not compiled in (rebuild with: make backend-pam)") + +// Authenticate always fails in the non-PAM build. +func Authenticate(service, username, password string) error { + return ErrUnavailable +} diff --git a/internal/server/basicauth.go b/internal/server/basicauth.go new file mode 100644 index 0000000..2a8d61e --- /dev/null +++ b/internal/server/basicauth.go @@ -0,0 +1,125 @@ +package server + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "log/slog" + "net/http" + "sync" + "time" +) + +// credCache memoises successful Basic-auth validations for a short TTL. Browsers +// resend the Authorization header on every request, so without this each one +// would trigger a full PAM round-trip (slow, and a brute-force/lockout risk). +// Only positive results are cached, keyed by a salted SHA-256 of user+password +// so the cache never holds a recoverable secret. A fresh random salt per process +// keeps keys from being precomputable. +type credCache struct { + mu sync.Mutex + ttl time.Duration + salt []byte + entries map[string]time.Time +} + +func newCredCache(ttl time.Duration) *credCache { + salt := make([]byte, 16) + _, _ = rand.Read(salt) + return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}} +} + +func (c *credCache) key(user, pass string) string { + h := sha256.New() + h.Write(c.salt) + h.Write([]byte(user)) + h.Write([]byte{0}) + h.Write([]byte(pass)) + return hex.EncodeToString(h.Sum(nil)) +} + +// valid reports whether user/pass was validated within the TTL. +func (c *credCache) valid(user, pass string) bool { + if c.ttl <= 0 { + return false + } + k := c.key(user, pass) + c.mu.Lock() + defer c.mu.Unlock() + exp, ok := c.entries[k] + if !ok { + return false + } + if time.Now().After(exp) { + delete(c.entries, k) + return false + } + return true +} + +// store records a successful validation, opportunistically pruning expired keys. +func (c *credCache) store(user, pass string) { + if c.ttl <= 0 { + return + } + k := c.key(user, pass) + now := time.Now() + c.mu.Lock() + defer c.mu.Unlock() + c.entries[k] = now.Add(c.ttl) + if len(c.entries) > 1024 { + for kk, exp := range c.entries { + if now.After(exp) { + delete(c.entries, kk) + } + } + } +} + +// basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM, +// see internal/pamauth). It mirrors kerberosAuth: a successful login writes the +// username into userHeader for the existing access pipeline (accessMiddleware / +// wsHandler), and any inbound value of userHeader is always discarded first so a +// client cannot spoof an identity. +// +// challenge controls the no/invalid-credentials case: +// - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the +// browser prompts for credentials. +// - challenge=false (WebSocket upgrade): fall through unauthenticated; the +// session resolves to default_user. Browsers cannot show a login dialog for a +// WebSocket, but once they have cached credentials from the page's API calls +// they resend them on the upgrade, so the validated path is still taken. +func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler { + if realm == "" { + realm = "uopi" + } + challengeValue := `Basic realm="` + realm + `", charset="UTF-8"` + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Never trust a client-supplied identity header; only a validated login sets it. + r.Header.Del(userHeader) + + if user, pass, ok := r.BasicAuth(); ok && user != "" { + if cache.valid(user, pass) { + r.Header.Set(userHeader, user) + next.ServeHTTP(w, r) + return + } + if err := authFn(user, pass); err == nil { + cache.store(user, pass) + r.Header.Set(userHeader, user) + next.ServeHTTP(w, r) + return + } else { + log.Warn("basic auth failed", "user", user, "err", err) + } + } + + // No or invalid credentials. + if challenge { + w.Header().Set("WWW-Authenticate", challengeValue) + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user + }) +} diff --git a/internal/server/basicauth_test.go b/internal/server/basicauth_test.go new file mode 100644 index 0000000..70a5307 --- /dev/null +++ b/internal/server/basicauth_test.go @@ -0,0 +1,129 @@ +package server + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +const testUserHeader = "X-Uopi-User" + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// echoUser is a terminal handler that reports the resolved identity header so a +// test can assert what basicAuth stamped. +func echoUser() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, r.Header.Get(testUserHeader)) + }) +} + +func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + if user != "" || pass != "" { + r.SetBasicAuth(user, pass) + } + if setHeader != "" { + r.Header.Set(testUserHeader, setHeader) // a spoof attempt + } + return r +} + +// authFn that accepts only alice/secret. +func aliceAuth(calls *int32) func(string, string) error { + return func(user, pass string) error { + atomic.AddInt32(calls, 1) + if user == "alice" && pass == "secret" { + return nil + } + return pamErr{} + } +} + +type pamErr struct{} + +func (pamErr) Error() string { return "bad credentials" } + +func TestBasicAuthChallengesWithoutCredentials(t *testing.T) { + cache := newCredCache(time.Minute) + h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, basicReq(t, "", "", "")) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } + if got := rec.Header().Get("WWW-Authenticate"); got == "" { + t.Fatalf("missing WWW-Authenticate challenge header") + } +} + +func TestBasicAuthValidCredentialsStampUser(t *testing.T) { + cache := newCredCache(time.Minute) + h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) + + rec := httptest.NewRecorder() + // Client also tries to spoof the identity header; it must be ignored. + h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root")) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if got := rec.Body.String(); got != "alice" { + t.Fatalf("want resolved user alice, got %q", got) + } +} + +func TestBasicAuthInvalidCredentialsRejected(t *testing.T) { + cache := newCredCache(time.Minute) + h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, basicReq(t, "alice", "wrong", "")) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rec.Code) + } +} + +func TestBasicAuthBestEffortFallsThrough(t *testing.T) { + cache := newCredCache(time.Minute) + // challenge=false (WebSocket path): no creds → pass through unauthenticated. + h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser()) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, basicReq(t, "", "", "")) + + if rec.Code != http.StatusOK { + t.Fatalf("want 200 pass-through, got %d", rec.Code) + } + if got := rec.Body.String(); got != "" { + t.Fatalf("want empty identity, got %q", got) + } +} + +func TestBasicAuthCachesSuccess(t *testing.T) { + var calls int32 + cache := newCredCache(time.Minute) + h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser()) + + for i := 0; i < 3; i++ { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, basicReq(t, "alice", "secret", "")) + if rec.Code != http.StatusOK { + t.Fatalf("req %d: want 200, got %d", i, rec.Code) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("want authFn called once (cached), got %d", got) + } +} diff --git a/internal/server/kerberos.go b/internal/server/kerberos.go new file mode 100644 index 0000000..2cd0a82 --- /dev/null +++ b/internal/server/kerberos.go @@ -0,0 +1,92 @@ +package server + +import ( + stdlog "log" + "log/slog" + "net/http" + "strings" + + "github.com/jcmturner/goidentity/v6" + "github.com/jcmturner/gokrb5/v8/keytab" + "github.com/jcmturner/gokrb5/v8/service" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +// internalUserHeader is the request header the built-in authentication +// middlewares (Kerberos, Basic) use to hand the validated username to the +// downstream access pipeline when no external TrustedUserHeader is configured. It +// is always stripped from inbound requests before validation, so a client cannot +// spoof it. +const internalUserHeader = "X-Uopi-User" + +// kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a +// successful handshake the authenticated principal's short username (realm +// stripped) is written into userHeader, so the existing access pipeline +// (accessMiddleware / wsHandler, which both read userHeader) resolves identity +// uniformly whether it originated from a trusted proxy header or a Kerberos +// ticket. +// +// challenge controls behaviour when a request carries no valid Negotiate +// credentials: +// - challenge=true (REST/page requests): delegate to gokrb5, which replies +// 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO. +// - challenge=false (WebSocket upgrades): fall through unauthenticated. +// Browsers cannot attach an Authorization header when opening a WebSocket; +// they only send Negotiate proactively to trusted URIs. When the header is +// present we still validate it, otherwise the session resolves to +// default_user exactly as before. +// +// Any inbound value of userHeader is always discarded before validation: with +// native Kerberos there is no trusted proxy stripping client-supplied headers, so +// only the SPNEGO-validated identity may set it. +func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler { + opts := []func(*service.Settings){ + service.Logger(stdlog.New(slogWriter{log: log}, "", 0)), + } + if spn != "" { + opts = append(opts, service.KeytabPrincipal(spn)) + } + + // inner runs only after a successful SPNEGO handshake: it copies the resolved + // identity into userHeader and continues down the chain. + inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := goidentity.FromHTTPRequestContext(r) + if id == nil { + http.Error(w, "kerberos: missing identity", http.StatusUnauthorized) + return + } + user := id.UserName() + if i := strings.IndexByte(user, '@'); i >= 0 { + user = user[:i] + } + r.Header.Set(userHeader, user) + next.ServeHTTP(w, r) + }) + validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Never trust a client-supplied identity header under native Kerberos. + r.Header.Del(userHeader) + + negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey) + if !negotiate && !challenge { + // Best-effort path (WebSocket without proactive credentials): continue + // unauthenticated; downstream resolves the session to default_user. + next.ServeHTTP(w, r) + return + } + // Credentials are present (validate them) or a challenge is required. + validate.ServeHTTP(w, r) + }) +} + +// slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO +// validation diagnostics surface without polluting normal output. +type slogWriter struct{ log *slog.Logger } + +func (s slogWriter) Write(p []byte) (int, error) { + if s.log != nil { + s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n")) + } + return len(p), nil +} diff --git a/internal/server/server.go b/internal/server/server.go index 3426b1f..4cf0c56 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,10 +4,12 @@ import ( "context" "io/fs" "log/slog" + "net" "net/http" "strings" "time" + "github.com/jcmturner/gokrb5/v8/keytab" "github.com/uopi/uopi/internal/access" "github.com/uopi/uopi/internal/api" "github.com/uopi/uopi/internal/audit" @@ -23,18 +25,35 @@ import ( const apiPrefix = "/api/v1" type Server struct { - httpServer *http.Server - log *slog.Logger + httpServer *http.Server + tlsCert string + tlsKey string + tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off + log *slog.Logger } // New creates the HTTP server, registers all routes, and returns a ready-to-start Server. // synth may be nil if the synthetic data source is not enabled. -func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server { +// +// basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated +// by that function (typically PAM): REST requests are challenged (401 Basic) and +// WebSocket upgrades validate proactively-resent credentials best-effort. It is +// mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both +// set the server serves HTTPS via ListenAndServeTLS. +func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server { if rec == nil { rec = audit.Nop() } mux := http.NewServeMux() + // When built-in authentication (Kerberos or Basic) is enabled but no external + // proxy header is configured, use an internal header to carry the validated + // username downstream. + userHeader := trustedUserHeader + if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" { + userHeader = internalUserHeader + } + // Health check mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti }) // WebSocket endpoint - mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug}) + var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug} // Prometheus-format metrics mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions)) @@ -50,11 +69,37 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti // REST API — registered on a dedicated mux so it can be wrapped with the // access-control middleware (identity resolution + global level enforcement). apiMux := http.NewServeMux() - api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix) - mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux)) + api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix) + var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux) + + // Native SPNEGO/Kerberos authentication (optional). REST/page requests are + // challenged (401 Negotiate); WebSocket upgrades validate proactively-sent + // credentials best-effort. Both stash the validated username in userHeader. + var frontendHandler http.Handler = http.FileServerFS(webFS) + + if krbKeytab != nil { + apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler) + wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH) + } else if basicAuthFn != nil { + // Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM). + // A shared positive-result cache spares a validation round-trip on every + // browser request. REST is challenged; WebSocket upgrades are best-effort. + cache := newCredCache(5 * time.Minute) + apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler) + wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH) + // Challenge the top-level page load too. Browsers only show the native + // Basic login dialog in response to a 401 on a navigation, not on the + // SPA's background fetch('/api/v1/me'); without this the user is never + // prompted and silently resolves to default_user. Once credentials are + // entered the browser caches them for the origin and resends them on + // every asset, API call and the WebSocket upgrade. + frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler) + } + mux.Handle("/ws", wsHandlerH) + mux.Handle(apiPrefix+"/", apiHandler) // Embedded frontend — must be last (catch-all) - mux.Handle("/", http.FileServerFS(webFS)) + mux.Handle("/", frontendHandler) return &Server{ httpServer: &http.Server{ @@ -64,7 +109,10 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti WriteTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, }, - log: log, + tlsCert: tlsCert, + tlsKey: tlsKey, + tlsRedirect: tlsRedirect, + log: log, } } @@ -108,15 +156,40 @@ func accessMiddleware(policy *access.Policy, userHeader string, next http.Handle // Start listens and serves until ctx is cancelled. func (s *Server) Start(ctx context.Context) error { - s.log.Info("listening", "addr", s.httpServer.Addr) + tls := s.tlsCert != "" && s.tlsKey != "" + s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls) errCh := make(chan error, 1) go func() { - if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + var err error + if tls { + err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey) + } else { + err = s.httpServer.ListenAndServe() + } + if err != nil && err != http.ErrServerClosed { errCh <- err } }() + // Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS + // service instead of letting them hit the TLS port with a cleartext request. + var redirectSrv *http.Server + if tls && s.tlsRedirect != "" { + redirectSrv = &http.Server{ + Addr: s.tlsRedirect, + Handler: httpsRedirectHandler(s.httpServer.Addr), + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + s.log.Info("http→https redirect listening", "addr", s.tlsRedirect) + go func() { + if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + } + select { case err := <-errCh: return err @@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error { shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.log.Info("shutting down") + if redirectSrv != nil { + _ = redirectSrv.Shutdown(shutCtx) + } return s.httpServer.Shutdown(shutCtx) } } + +// httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service. +// It preserves the requested hostname and path, swapping in the TLS listener's +// port (omitted when 443). +func httpsRedirectHandler(tlsAddr string) http.Handler { + _, tlsPort, _ := net.SplitHostPort(tlsAddr) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + target := "https://" + host + if tlsPort != "" && tlsPort != "443" { + target += ":" + tlsPort + } + target += r.URL.RequestURI() + http.Redirect(w, r, target, http.StatusMovedPermanently) + }) +} diff --git a/uopi.example.toml b/uopi.example.toml index 76926c9..fb3326d 100644 --- a/uopi.example.toml +++ b/uopi.example.toml @@ -2,6 +2,14 @@ listen = ":8080" storage_dir = "./interfaces" +# ── UI presentation defaults (sent to the browser) ────────────────────────── +# default_zoom is the base UI scale used when a browser has no saved zoom of its +# own. Handy for HiDPI screens (e.g. 4K) whose OS scaling is left at 100%, where +# the browser reports devicePixelRatio=1 and the UI would otherwise be tiny. The +# in-app A+/A− control still overrides it per browser. 1.0 = no scaling. +[ui] +default_zoom = 1.0 # e.g. 1.5 or 1.75 on dense displays + # ── Identity & access control ─────────────────────────────────────────────── # The end-user identity is read from a header set by a trusted authenticating # reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use. @@ -11,6 +19,74 @@ trusted_user_header = "" # e.g. "X-Forwarded-User" # Identity used when the trusted header is absent/empty. Empty = anonymous. default_user = "" +# ── Native Kerberos / SPNEGO authentication ───────────────────────────────── +# Alternative to a reverse proxy: uopi authenticates browsers directly via +# SPNEGO ("Negotiate"). Recommended when proxy-header auth lets some browsers +# (notably Firefox) silently fall through to default_user. When enabled, uopi +# challenges API requests with 401 WWW-Authenticate: Negotiate and resolves the +# user from the validated Kerberos ticket (short principal name, realm stripped); +# any inbound trusted_user_header value is ignored to prevent spoofing. +# Browsers must be told to perform SPNEGO for this origin +# (Firefox: network.negotiate-auth.trusted-uris). +[server.kerberos] +enabled = false +# Path to the service keytab holding the HTTP service principal's key +# (e.g. HTTP/host.example.com@REALM). Required when enabled. +keytab = "" # e.g. "/etc/uopi/http.keytab" +# Optionally pin which keytab principal to accept tickets for. Empty = default. +service_principal = "" # e.g. "HTTP/host.example.com" + +# ── Built-in HTTP Basic authentication (PAM) ──────────────────────────────── +# Another standalone alternative (no reverse proxy, no Kerberos): uopi challenges +# with 401 WWW-Authenticate: Basic and validates the username/password through the +# host PAM stack (/etc/pam.d/). On an SSSD/LDAP-joined host this +# reuses each user's normal login password with no directory config in uopi. The +# validated username feeds the same access pipeline. Mutually exclusive with +# Kerberos. REQUIRES a PAM-enabled build: `make backend-pam` (cgo + libpam); the +# default static binary cannot validate and refuses to start with this enabled. +# Basic credentials travel on every request — enable [server.tls] below unless on +# a fully isolated network. +[server.basic_auth] +enabled = false +# PAM service name under /etc/pam.d/. Empty defaults to "uopi". +pam_service = "" # e.g. "uopi" or "login" + +# ── Built-in HTTP Basic authentication (LDAP) ─────────────────────────────── +# Pure-Go alternative to basic_auth/PAM: validates the Basic credentials directly +# against an LDAP directory using "search then bind" — the same flow an SSSD/LDAP +# client uses. Needs NO cgo, so it works in the default fully-static binary +# (`make release`), unlike PAM. Mutually exclusive with kerberos and basic_auth. +# Defaults mirror SSSD: user_attr → "uid", user_object_class → "posixAccount", +# empty bind_dn → anonymous search. Enable TLS below (or use ldaps://) so +# passwords are not sent in clear text. +[server.ldap] +enabled = false +# Directory endpoint(s), tried in order (SSSD ldap_uri). +uri = ["ldaps://ldap.example.com"] +# Subtree user entries live under (SSSD ldap_search_base). +search_base = "dc=example,dc=com" +# Attribute matched against the login name. Empty defaults to "uid". +user_attr = "" # e.g. "uid" (RFC2307) or "sAMAccountName" (AD) +# Object class to restrict the search. Empty defaults to "posixAccount". +user_object_class = "" # e.g. "posixAccount" or "user" (AD) +# Optional service account for the search. Empty bind_dn = anonymous search. +bind_dn = "" +bind_password = "" +# Upgrade a plain ldap:// connection to TLS before binding (ignored for ldaps://). +start_tls = false +# Optional PEM CA bundle to trust a private directory CA. +ca_cert = "" # e.g. "/etc/uopi/ldap-ca.pem" +# Disable TLS certificate verification (testing only — insecure). +insecure_skip_verify = false + +# ── Built-in TLS / HTTPS ──────────────────────────────────────────────────── +# Terminate HTTPS directly without a reverse proxy. Strongly recommended whenever +# basic_auth is enabled. Cert and key are PEM paths; both required when enabled. +[server.tls] +enabled = false +cert = "" # e.g. "/etc/uopi/tls/cert.pem" +key = "" # e.g. "/etc/uopi/tls/key.pem" + # Role-based access through group memberships. Each [[groups]] block lists # members by role; a user's effective global capability is the highest role # across all their memberships, along this ladder: diff --git a/web/src/App.tsx b/web/src/App.tsx index 609a900..0df48d2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,7 +5,7 @@ import ViewMode from './ViewMode'; import EditMode from './EditMode'; import FullscreenMode from './FullscreenMode'; import ControlDialogs from './ControlDialogs'; -import { applyZoom, getStoredZoom } from './ZoomControl'; +import { applyZoom, getStoredZoom, setServerDefaultZoom, watchDpr } from './ZoomControl'; import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth'; import type { Interface, Me } from './lib/types'; @@ -29,11 +29,14 @@ export default function App() { }, []); useEffect(() => { - const dpr = window.devicePixelRatio ?? 1; - document.documentElement.style.setProperty('--dpr', String(dpr)); - applyZoom(getStoredZoom()); + applyZoom(getStoredZoom(), false); + const unwatchDpr = watchDpr(); if (!fsParam) wsClient.connect('/ws'); - fetchMe().then(setMe); + fetchMe().then(me => { + setMe(me); + setServerDefaultZoom(me.defaultZoom); + }); + return unwatchDpr; }, []); if (fsParam) { diff --git a/web/src/ConfigManager.tsx b/web/src/ConfigManager.tsx index 1572534..cc0a6d1 100644 --- a/web/src/ConfigManager.tsx +++ b/web/src/ConfigManager.tsx @@ -3,6 +3,8 @@ import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; import { VersionTree, DiffViewer } from './VersionHistory'; import CueEditor from './CueEditor'; +import { useAuth } from './lib/auth'; +import { ScopeFilter, ScopePicker, filterByScope, normalizeScope, type Bucket } from './lib/scope'; // ── Types (mirror internal/confmgr) ────────────────────────────────────────── @@ -32,6 +34,8 @@ interface ConfigSet { version: number; tag?: string; owner?: string; + scope?: string; + groups?: string[]; parameters: Parameter[]; } @@ -43,10 +47,12 @@ interface ConfigInstance { version: number; tag?: string; owner?: string; + scope?: string; + groups?: string[]; values: Record; } -interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; } +interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; owner?: string; scope?: string; groups?: string[]; } // ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation // logic bound to a set, run when instances of that set are saved. @@ -292,7 +298,10 @@ function useUndoKeys(undo: () => void, redo: () => void, canUndo: boolean, canRe // ── Sets manager ────────────────────────────────────────────────────────────── function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) { + const me = useAuth(); const [sets, setSets] = useState([]); + const [bucket, setBucket] = useState('mine'); + const [scopeGroup, setScopeGroup] = useState(''); const [selectedId, setSelectedId] = useState(null); const hist = useUndo(); const working = hist.present; @@ -445,6 +454,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) } catch (err) { setError(msg(err)); } } + const visibleSets = filterByScope(sets, me, bucket, scopeGroup); + return (
    @@ -457,8 +468,10 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) { const t = e.target as HTMLInputElement; const f = t.files?.[0]; if (f) importSet(f); t.value = ''; }} />
    + {sets.length === 0 &&
    No config sets yet.
    } - {sets.map(s => ( + {sets.length > 0 && visibleSets.length === 0 &&
    No config sets in this view.
    } + {visibleSets.map(s => (
    select(s.id)}> {s.name || '(unnamed)'} v{s.version} @@ -484,6 +497,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null)