diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e377c6..ebd60e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ orchestration code; see each submodule's own `CHANGELOG.md` [sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)) for what changed inside the apps it composes. +## [v1.35.17] - 2026-08-04 + +### Added +- **Group & Permission Model spec** — canonical documentation of the hierarchical group schema (`god_admin`, `{site}_super_admin`, per-site/per-resource host+app `admin`/`access`/`` groups, meta `everyone`/`{site}_everyone`), the inheritance resolver, Directory-only group management, multi-site isolation, host-side SSSD GID mapping (groups are `groupOfNames`, no `gidNumber`), downstream-app consumption, and migration from the legacy `app_*` groups. See [GROUPS.md](GROUPS.html). +- **sso v1.25.0** — the resolver + schema implemented in the SSO (see its changelog); the standalone Groups page removed. + ## [v1.35.16] - 2026-08-04 ### Added diff --git a/docs/GROUPS.md b/docs/GROUPS.md new file mode 100644 index 0000000..a00b674 --- /dev/null +++ b/docs/GROUPS.md @@ -0,0 +1,312 @@ +--- +layout: default +title: Group & Permission Model +nav_order: 3 +--- + +# Theta42 Group & Permission Model + +This is the canonical reference for how **groups and permissions work** across the +theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux +hosts** should read and use them. It is written to be implementable by both humans +and LLM agents. + +Everything below assumes LDAP is the single source of truth for identity and group +membership. Group membership is managed in the **SSO Manager Directory**, generated +from adopted resources — there is **no standalone "Groups" page**. + +--- + +## 1. Principles + +1. **Groups are a projection of the resource graph.** Every adopted host and app + in the Directory gets its own groups, auto-created from its identity. Group + membership is managed on the resource's modal. +2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers + hosts; an app administers apps. They do not inherit from each other. +3. **Three levels per resource: `admin`, `access`, and opaque `capability`.** + `admin` implies `access`. Capabilities are explicit and never implied by + `admin`. +4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by + the site slug. +5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC) + with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it + needs and generate their GIDs on the fly (see §8) — no mass import, no GID + bloat. Only the meta groups are never imported by hosts. +6. **The directory is the only place groups are created.** `god_admin` is the sole + group that does not belong to a resource or site. + +--- + +## 2. Group schema + +`S` = site slug (see §7 for normalization). ``/`` = the resource slug. +`` = an opaque, app-defined capability token (see §4). + +| Group | Scope | Meaning | +| :--- | :--- | :--- | +| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. | +| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). | +| `S_hosts_admin` | site | Admin on **all hosts** at `S`. | +| `S_hosts_access` | site | Access to **all hosts** at `S`. | +| `S_hosts_` | site | Capability `` on **all hosts** at `S`. | +| `S_host__admin` | host | Admin on host ``. | +| `S_host__access` | host | Access to host ``. | +| `S_host__` | host | Capability `` on host ``. | +| `S_apps_admin` | site | Admin on **all apps** at `S`. | +| `S_apps_access` | site | Access to **all apps** at `S`. | +| `S_apps_` | site | Capability `` on **all apps** at `S`. | +| `S_app__admin` | app | Admin on app ``. | +| `S_app__access` | app | Access to app ``. | +| `S_app__` | app | Capability `` on app ``. | + +### Meta groups (implicit membership — not POSIX, no gidNumber) + +| Group | Scope | Meaning | +| :--- | :--- | :--- | +| `everyone` | global | **All authenticated users**, any site. | +| `S_everyone` | site | **All authenticated users** at site `S`. | + +These are resolved by the directory (any authenticated user passes), never +enumerated as LDAP members, and cannot be used as Unix groups. + +--- + +## 3. Naming, normalization & reserved rules + +- The **structural delimiter is `_`**. It appears only between the fixed segments + of a group name. +- **Site, host, and app slugs never contain `_`.** Normalize to lowercase; + spaces and `_` → `-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a + site `Main Office` produce slugs `web-01` and `main-office`. +- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups + use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even + if a host were named `admin` (that host would be `S_host_admin_admin`). +- **The last segment is the level.** If it is `admin` or `access` it is a known + level; any other value is an **opaque capability** owned by a downstream app. +- **Total length budget:** keep a group cn under ~120 chars; reject group + creation that would exceed it. +- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are + generated on the host by SSSD for only the groups that host imports (see §8). + +--- + +## 4. Levels and opaque capabilities + +- **`admin`** — manage (create/update/delete/config) the resource. +- **`access`** — use/read the resource. +- **``** — an arbitrary token the SSO does **not** interpret. The SSO + manages membership and exposes the group to the app; **the downstream app + defines and enforces what the capability means** (e.g. `emby_admin`, + `gitea_maintain`, `reboot`, `backup`). + +The directory recognizes `admin`, `access`, `super_admin`, and the meta groups. +Everything else on a resource group is treated as an opaque capability group and +passed through to consumers. + +--- + +## 5. Permission resolution (inheritance) + +Define a user's **effective permission** on a resource by checking, from most +specific to most general, whether they are a member of any applicable group. The +rule: a higher group implies everything below it. + +### On host `H` at site `S` + +| Wanted | Granted if the user is a member of **any** of | +| :--- | :--- | +| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` | +| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` | +| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` | + +### On app `A` at site `S` + +Identical, with `app`/`apps` substituted for `host`/`hosts`. + +### Management console (SSO / Proxy / Jump-Host) + +Each console is registered as an **app** on its site, so console admin is: + +`god_admin` · `S_super_admin` · `S_app__admin` + +### Pseudocode + +``` +def effective(resource, level_or_cap, site): + if user in "god_admin": return True + if user in f"{site}_super_admin": return True + if level_or_cap in ("admin","access"): + agg = f"{site}_{resource.kind}s_{level_or_cap}" + if user in agg: return True + specific = f"{site}_{resource.kind}_{resource.slug}_{level_or_cap}" + if user in specific: return True + if level_or_cap == "access": return effective(resource, "admin", site) + if level_or_cap == "admin": return False # access does not imply admin + return False +``` + +`everyone` / `S_everyone` are a special grantee: if a resource grants a group to +`everyone` (or `S_everyone`), any authenticated user (at that site) passes. + +--- + +## 6. Where groups live — the Directory, generated from adopted resources + +- There is **no standalone Groups page.** Group creation/management happens on an + **adopted resource** in the Directory. +- When a host or app is **adopted** (promoted from Discovered Inventory to + managed), the directory auto-creates its `_admin` and `_access` groups (and + site aggregates if configured). Capability groups are created on demand. +- Membership (add/remove users) and capability grants are managed on that + resource's modal. +- Deleting a resource removes its per-resource groups. +- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the + global `god_admin`/`everyone` are managed at the site level (not on a single + host/app resource). + +--- + +## 7. Multi-site isolation + +One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo", +"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*` +groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin` +touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and +`everyone` cross site boundaries. + +--- + +## 8. Unix/POSIX groups — mapped on the host, not in LDAP + +Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**. +There are hundreds of them and only a handful matter on any given host, so we do +**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the +groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping). +This keeps the directory clean and the per-host surface tiny. + +### SSSD — generate GIDs on the fly, import only what you need + +```ini +[domain/example] +id_provider = ldap +auth_provider = ldap +ldap_uri = ldaps://ldap.example +ldap_search_base = dc=example,dc=com + +# groupOfNames (RFC 2307bis) schema +ldap_schema = rfc2307bis +ldap_group_object_class = groupOfNames +ldap_group_member = member + +# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP +ldap_id_mapping = true +ldap_group_uuid = entryUUID + +# Import ONLY the groups this host needs (e.g. a naming convention or an OU) +ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*)) +``` + +Key ideas: +- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a + stable GID for any group it imports, so **no `gidNumber` attribute is required** + in LDAP. +- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that + match, discarding the other hundreds. After changing the filter, clear the + cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with + `getent group `. + +### What filter to use — the naming convention is the answer + +A host should import its **own** resource groups (plus any explicitly granted +ones). Because the schema is predictable, `ldap-client` can generate the per-host +`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01` +at site `main-office` imports: + +``` +(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access) + (cn=main-office_host_web01_admin) + (cn=main-office_host_web01_sudo))) +``` + +So the operator (or ldap-client) selects a small allowlist of the host's `_access` +/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem +ACLs. **Only those groups are imported** — no GID bloat, no mass import. + +### Aliasing an LDAP group into a local group (e.g. `input`) + +SSSD cannot merge an LDAP group into a local group whose GID varies per host. +Two host-side mechanisms cover it: + +- **pam_exec** — a script in the login stack adds the user to the local group for + the session: + ```sh + #!/bin/bash + if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi + ``` + `session optional pam_exec.so /usr/local/bin/add_to_input.sh` in + `/etc/pam.d/common-session`. + +- **nss-groupmerge** — merge an LDAP group into a local group at NSS time + (`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge` + in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's + members regardless of the local GID. + +### Meta groups + +`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have +implicit membership and are resolved by the directory only. + +--- + +## 9. Downstream-app consumption guide + +A downstream app (Emby, Gitea, a custom service, a shell script) reads group +membership from LDAP and interprets it as follows: + +1. **Discover the user's groups** — bind with the user's credentials (or use a + service account + `memberOf`). Groups are `groupOfNames` (member DN), so query + by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=))`, or use + the `memberOf` reverse attribute on the user's entry. +2. **Match each group to a scope:** + - `god_admin` → the user is a global administrator. + - `{site}_super_admin` → site administrator for that site. + - `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site. + - `{site}_host__*` / `{site}_app__*` → applies to that one resource. + - `everyone` / `{site}_everyone` → the user is implicitly a member. +3. **Interpret the last segment:** + - `admin` → full control of that resource. + - `access` → read/use. + - anything else → a capability **you** define; act on it or ignore it. +4. A user with `{site}_host_web01_access` can reach `web01`; a user with + `{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with + `{site}_app_emby_emby_admin` administers Emby. + +The app must **never** treat an unknown last segment as `admin` or `access`. + +--- + +## 10. Migration from the legacy `app_*` groups + +The current global groups (`app_sso_admin`, `app_super_admin`, +`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model: + +| Legacy | New | +| :--- | :--- | +| `app_super_admin` | `god_admin` | +| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) | +| `app_sso_directory_admin` | `S_app_sso_admin` | +| `app_jump_admin` | `S_app_jump_admin` | + +During the transition the legacy groups may be kept as short-lived aliases that +resolve to the same effective permission; once everything is moved, remove them. + +--- + +## 11. The management consoles are apps + +The SSO, Proxy, and Jump-Host each register themselves as an app on their site and +receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`, +`S_app_jump_admin`, plus `_access`). Their admin UIs gate on +`god_admin` · `S_super_admin` · `S_app__admin`. This keeps everything +self-consistent: the SSO is "just another app." diff --git a/docs/index.md b/docs/index.md index a8d1cce..3f2e1d6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,6 +50,10 @@ snapshots state before every rebuild. - **Proxy** — add the hosts you want to protect with OIDC login. - **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and LDAP-native apps authenticate against the same directory. +- **Hierarchical groups & permissions** — every adopted host and app gets its own + `admin`/`access`/`capability` groups, generated from the Directory; they double + as real POSIX groups for sudo/SSH. See + [Group & Permission Model](GROUPS.html). - **ldap-client** — enroll Linux hosts into the directory (PAM/SSSD login, sudo, SSH keys); the host inventory shows up in the SSO UI and drives jump-host routing.