diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c9bd15..1bf7cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v1.25.0 +- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host__admin/access/; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups +- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar) +- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases) + # v1.24.0 - feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery - feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config 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/nodejs/routes/docs.js b/nodejs/routes/docs.js index 5351b96..968b0e9 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -37,6 +37,7 @@ const DOCS = { agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')}, plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')}, vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')}, + groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')}, diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 144a4a3..5924fa8 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -188,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) { res.render('profile', {...values}); }); -router.get('/groups', function(req, res, next) { - res.render('groups', {...values}); -}); - router.get('/token', function(req, res, next) { res.render('token', {...values}); }); diff --git a/nodejs/routes/user.js b/nodejs/routes/user.js index 62da372..452e1f8 100755 --- a/nodejs/routes/user.js +++ b/nodejs/routes/user.js @@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){ // same answer in both modes. const groups = await groupCns(user); user.groups = groups; - user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP); + // Console admin under the group model (docs/GROUPS.md §11): god_admin, + // a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the + // legacy app_sso_admin/app_super_admin during migration. + user.isAdmin = groups.some((g) => + g === 'app_sso_admin' || g === 'app_super_admin' || + g === permission.SUPER_ADMIN_GROUP || + g.endsWith('_super_admin') || g.endsWith('_app_sso_admin')); return res.json(user); }catch(error){ diff --git a/nodejs/tests/groups.test.js b/nodejs/tests/groups.test.js new file mode 100644 index 0000000..3450f8a --- /dev/null +++ b/nodejs/tests/groups.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const { + slugify, + resourceGroupCns, + aggregateGroupCns, + siteSuperAdminCns, + siteEveryoneCns, + isKnownLevel, + levelGrants, + hasPermission, + GOD_ADMIN, +} = require('../utils/groups'); + +const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' }; +const APP = { site: 'main-office', kind: 'app', slug: 'emby' }; +const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' }; + +describe('slugify', () => { + test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => { + expect(slugify('Web 01')).toBe('web-01'); + expect(slugify('Main Office')).toBe('main-office'); + expect(slugify('my_host')).toBe('my-host'); + expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name'); + expect(slugify('')).toBe(''); + }); + test('never contains an underscore (the structural delimiter)', () => { + expect(slugify('a_b_c')).not.toContain('_'); + expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__'); + }); +}); + +describe('group cn builders', () => { + test('per-resource uses singular kind', () => { + expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin'); + expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access'); + }); + test('aggregate uses plural kind', () => { + expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin'); + expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access'); + }); + test('site super admin + everyone', () => { + expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin'); + expect(siteEveryoneCns('main-office')).toBe('main-office_everyone'); + }); + test('invalid kind throws', () => { + expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow(); + }); +}); + +describe('levels', () => { + test('admin/access known; capabilities opaque', () => { + expect(isKnownLevel('admin')).toBe(true); + expect(isKnownLevel('access')).toBe(true); + expect(isKnownLevel('reboot')).toBe(false); + expect(isKnownLevel('emby_admin')).toBe(false); + }); + test('admin implies access; access does not imply admin', () => { + expect(levelGrants('admin', 'access')).toBe(true); + expect(levelGrants('access', 'admin')).toBe(false); + }); +}); + +describe('hasPermission — inheritance', () => { + test('god_admin grants everything everywhere', () => { + expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true); + expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true); + expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true); + expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true); + }); + + test('site super admin grants everything on its site, not other sites', () => { + expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true); + expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true); + expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false); + }); + + test('aggregate (all hosts) grants on any host at the site', () => { + expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true); + expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true); + expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true); + }); + + test('specific host group grants only that host', () => { + const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + expect(hasPermission([cn], HOST, 'admin')).toBe(true); + expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false); + }); + + test('admin implies access; access does not imply admin', () => { + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false); + }); + + test('capabilities are exact — admin does not grant a capability', () => { + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true); + expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false); + // aggregate capability + expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true); + }); + + test('hosts and apps are orthogonal namespaces', () => { + const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + expect(hasPermission([hostAdmin], APP, 'access')).toBe(false); + const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin'); + expect(hasPermission([appAdmin], APP, 'access')).toBe(true); + }); + + test('cross-site isolation', () => { + const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin'); + expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false); + expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true); + }); +}); diff --git a/nodejs/utils/groups.js b/nodejs/utils/groups.js new file mode 100644 index 0000000..090c9eb --- /dev/null +++ b/nodejs/utils/groups.js @@ -0,0 +1,123 @@ +'use strict'; + +// Theta42 group & permission model. +// +// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed, +// parseable structure. The structural delimiter is `_`; site/host/app slugs +// never contain it. Aggregates use the plural kind (hosts/apps); per-resource +// uses the singular (host/app). +// +// god_admin global — everything, everywhere +// {site}_super_admin everything on the site +// {site}_hosts_ admin/access/capability on ALL hosts at the site +// {site}_hosts_ +// {site}_host__ admin/access/capability on ONE host +// {site}_apps_ ... on ALL apps at the site +// {site}_app__ ... on ONE app +// {site}_everyone / everyone meta groups (implicit membership) +// +// `level` is 'admin', 'access', or an opaque ``. `admin` implies +// `access`; capabilities are explicit and never implied by `admin`. Groups are +// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD). +// +// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers +// supply the user's group memberships (e.g. from Group.list(user.dn)). + +const GOD_ADMIN = 'god_admin'; +const KNOWN_LEVELS = ['admin', 'access']; +const KINDS = ['host', 'app']; + +// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never +// contains '_' (the structural delimiter), so group names parse unambiguously. +function slugify(name) { + return String(name || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// Validate a kind (host/app) — throw on anything else. +function assertKind(kind) { + if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`); +} + +// {site}_host__ / {site}_app__ +function resourceGroupCns(site, kind, slug, level) { + assertKind(kind); + return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`; +} + +// {site}_hosts_ / {site}_apps_ (plural kind — the aggregate). +function aggregateGroupCns(site, kind, level) { + assertKind(kind); + return `${slugify(site)}_${kind}s_${level}`; +} + +// {site}_super_admin +function siteSuperAdminCns(site) { + return `${slugify(site)}_super_admin`; +} + +// {site}_everyone +function siteEveryoneCns(site) { + return `${slugify(site)}_everyone`; +} + +// True if `level` is a known admin/access level (not an opaque capability). +function isKnownLevel(level) { + return KNOWN_LEVELS.includes(level); +} + +// True if holding `level` grants `wanted` (admin implies access). +function levelGrants(level, wanted) { + if (level === wanted) return true; + return level === 'admin' && wanted === 'access'; +} + +// Resolve whether a user (given `memberOf` — the group cns they belong to) has +// `level` on a resource. Applies the inheritance lattice: +// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access. +// +// memberOf: array of group cns the user is a member of. +// resource: { site, kind: 'host'|'app', slug }. +// level: 'admin' | 'access' | an opaque capability token. +// +// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they +// are resource-level grants, resolved by the caller against the resource's own +// granted groups (see permission.onResource). This keeps the function pure over +// the user's membership only. +function hasPermission(memberOf, resource, level) { + const site = slugify(resource && resource.site); + const kind = resource && resource.kind; + const slug = slugify(resource && resource.slug); + const set = new Set(memberOf || []); + + if (set.has(GOD_ADMIN)) return true; + if (set.has(siteSuperAdminCns(site))) return true; + + if (isKnownLevel(level)) { + // admin / access + if (set.has(aggregateGroupCns(site, kind, level))) return true; + if (set.has(resourceGroupCns(site, kind, slug, level))) return true; + if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true; + return false; + } + // Opaque capability — exact aggregate or specific grant only. + if (set.has(aggregateGroupCns(site, kind, level))) return true; + if (set.has(resourceGroupCns(site, kind, slug, level))) return true; + return false; +} + +module.exports = { + GOD_ADMIN, + KNOWN_LEVELS, + KINDS, + slugify, + resourceGroupCns, + aggregateGroupCns, + siteSuperAdminCns, + siteEveryoneCns, + isKnownLevel, + levelGrants, + hasPermission, +}; diff --git a/nodejs/utils/permission.js b/nodejs/utils/permission.js index 15fc2e0..a5280c5 100644 --- a/nodejs/utils/permission.js +++ b/nodejs/utils/permission.js @@ -1,10 +1,20 @@ 'use strict'; const {Group} = require('../models/group_ldap'); +const groups = require('./groups'); -const SUPER_ADMIN_GROUP = 'app_super_admin'; +// The global god-admin group (everything, everywhere). During migration the +// legacy `app_super_admin` is recognized as an alias (docs/GROUPS.md §10). +const SUPER_ADMIN_GROUP = groups.GOD_ADMIN; +const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin']; -let byGroup = async function(user, groups, ownerOf){ +// True if the user (by resolved member cns) is a global god/super admin. +async function isSuperAdmin(memberOfCns) { + return memberOfCns.includes(groups.GOD_ADMIN) || + memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn)); +} + +let byGroup = async function(user, checkGroups, ownerOf){ // Membership is resolved once, transitively: a user placed in an admin group // through a nested group is as much a member as one listed on it directly. // Checking `group.member.includes(user.dn)` per group -- as this used to -- @@ -17,9 +27,9 @@ let byGroup = async function(user, groups, ownerOf){ // they still catch direct membership if the resolver is unavailable. } - if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true; + if(await isSuperAdmin(memberOfCns)) return true; - for(let group of groups){ + for(let group of checkGroups){ if(memberOfCns.includes(group)) return true; } @@ -42,4 +52,46 @@ let byGroup = async function(user, groups, ownerOf){ throw error; } -module.exports = {byGroup, SUPER_ADMIN_GROUP}; +// Resolve whether a user has `level` on a directory resource under the group +// model (see utils/groups.js). Applies the inheritance lattice and the +// `everyone`/`{site}_everyone` meta grants when the resource grants them. +// +// user: the auth user ({ dn, isMachine }). +// resource:{ site, kind: 'host'|'app', slug }. +// level: 'admin' | 'access' | an opaque capability token. +// grantedGroups: optional array of the resource's granted group cns (used only +// for meta `everyone` handling). Omit to skip meta grants. +async function onResource(user, resource, level, grantedGroups) { + let memberOfCns = []; + try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ } + + if (await isSuperAdmin(memberOfCns)) return true; + if (groups.hasPermission(memberOfCns, resource, level)) return true; + + // Meta grants: `everyone` / `{site}_everyone` confer access to any + // authenticated (non-machine) user when the resource grants them. + if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) { + const siteEveryone = groups.siteEveryoneCns(resource.site); + if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true; + } + return false; +} + +// Like onResource but throws Insufficient Permission when denied — for guards. +async function requireResource(user, resource, level, grantedGroups) { + if (await onResource(user, resource, level, grantedGroups)) return; + const error = new Error('Insufficient Permission'); + error.name = 'Insufficient Permission'; + error.status = 401; + throw error; +} + +module.exports = { + byGroup, + onResource, + requireResource, + isSuperAdmin, + SUPER_ADMIN_GROUP, + LEGACY_SUPER_ADMIN_ALIASES, + ...groups, // group schema builders (slugify, resourceGroupCns, ...) +}; diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 4128e3d..74eedee 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -41,7 +41,6 @@ module.exports = { // Catalog requires login - it's the end-user view of their accessible resources. {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']}, {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, - {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']}, {href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, // Vault requires login - per-user secrets at secret/users//*. diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 25a7125..094b4bc 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -30,6 +30,7 @@
Directory Management +
diff --git a/nodejs/views/groups.ejs b/nodejs/views/groups.ejs deleted file mode 100644 index b16de27..0000000 --- a/nodejs/views/groups.ejs +++ /dev/null @@ -1,406 +0,0 @@ -<%- include('top') %> - - -
- -
-
- - -
- - -
-
-
-
-
- - Add new group - -
- -
-
-
- - -
- -
- - -
- - -
-
-
-
-
-
- - -
-

- {{ description }} -

-
-
-

-

    - {{ #member }} -
  • - {{ uid }} - -
  • - {{ /member }} -
-

- -
- -
-

- Everyone in a nested group is a member of this one, at any depth. -

-
    - {{ #nested }} -
  • - {{ cn }} - -
  • - {{ /nested }} - {{ ^hasNested }} -
  • No groups nested here.
  • - {{ /hasNested }} -
- -
- -
-

-

    - {{ #owner }} -
  • - {{ uid }} - -
  • - {{ /owner }} -
-

- - - -
-
-
- -
-
-
- -
-<%- include('bottom') %>