feature/k8s-rbac #8

Merged
mhahl-a merged 62 commits from feature/k8s-rbac into main 2026-08-13 06:23:59 +00:00
Owner
No description provided.
Introduce rbac schema (roles, role_rules, bindings) and api.can() with
cluster→team→project→secret scope inheritance. Built-in roles mirror classic
team/project/secret/service access; reveal remains a distinct verb.

Authorization helpers (can_read/write/admin_project, can_access_secret,
team_role, project_role) evaluate bindings only — start-fresh, no migration
from team_members / project_members / secret_acl.

Admin UI: /rbac/roles, /rbac/bindings (familiar dropdowns), /rbac/access-review.
Team create still grants team-owner binding to the creator.
Container only copies app/, so ensure_schema could not find db/rbac.sql.
Bindings list enriches user emails via admin DSN instead of RLS-blocked
private.users.
- Tabs for built-in / custom / create
- Card grid for built-in roles; cleaner custom table
- Create form with Form vs YAML rule editor (multi-rule text)
- Sidebar label Access (RBAC) → Role bindings
- New sidebar Access group: Reveal requests, Role bindings, Roles, Access review
- Administration keeps Auditing and Server settings
- Built-in roles use the standard table layout
Use the shared tabs pattern for mode switching and expand the create
role surface (name fields, checkboxes, YAML editor) to fill the page.
Replace legacy secret_acl grants UI with bindings to secret-read /
secret-reveal / secret-write. Access mode inherit vs restricted (custom)
enforced in can_access_secret_row; exclusive mode only counts
secret-scope bindings (plus project admins).
- Catch MASTER_KEY mismatch on secret view instead of 500
- Sync team_members, project_members, group team_role, and
  project_group_roles into rbac.bindings on write
- Add backfill helper for existing legacy rows
Remove legacy api.secret_acl (init, ensure_schema, seed, docs, tests).
Per-secret grants stay as secret-scope rbac.bindings. can_manage_rbac
lets project admins manage secret scope. Team → Access tab, open Access
nav for team admins, scope-aware pickers and breadcrumbs on Role bindings.
Access is a first-class team tab (?tab=access) with list/add/remove
bindings, not an external link. Show for team owner/admin, global admin,
or can_manage_rbac. Force-recreate-friendly deploy.
Removing the EXISTS(secret_acl) branch left orphan parentheses, so
ensure_schema failed with a syntax error and every request 500'd.
Project members and group roles write rbac.bindings only (no dual-write).
Reveal-approval tab renamed Requests; project Access mirrors team Access
with a shared card form (Who / Role field groups). Team Access uses the
same panel. Mgmt API project members list/add/remove use bindings.
Single Access nav item “Roles & bindings” with Bindings | Roles tabs.
Remove quick-paths blurb, obsolete dual templates, and start-fresh empty
copy. /rbac/roles redirects to the roles panel.
Restore separate Access sidebar items. Improve secrets-list HTMX spinner
and project empty-description link. Document admin floor (R3) and
Restricted exclusive mode (R1); skip role-edit blast radius (R2).
Drop the combined page tabs. Each sidebar item is its own page; description
sits under the title (not beside it). Remove built-in badges from roles list.
- Group team access is a role binding only (no groups.team_role UI writes)
- Guard last team-owner on bindings (Python + SQL trigger)
- Seed memberships/group roles via rbac.bindings only
- Update team/project member tests for can_manage_rbac paths
Groups are membership containers only. Team access is granted by binding
a Group subject under Members (or project Access). Drop the dedicated
group team-role form and route.
Restore Access on teams for role bindings (users/groups/SAs); Members is
people + invites. Project Access unchanged. Built-in role renamed
cluster-admin → global-admin (with legacy rename in ensure_builtin).
The admin binding email-enrichment loop reused the name `row`, clobbering
the outer secret row after iterating. Once a project admin with a user
secret-binding opened the secret view, `row["key"]` raised KeyError when
rendering/auditing the reveal. Rename the loop variable to urow.

Adds a regression test (reproduces the exact KeyError) and a plain GET
secret_view test.
- Project settings tab: group controls into fieldset components and move
  explanatory copy into per-field hints so it no longer crowds the controls.
- Secret quick-add form: drop the Type selector and its hint text; only plain
  secrets are created here (structured types go through Advanced create).
- Machine-account create form: restyle to the project theme (section card +
  field-group/field-grid), remove all emdashes, and drop now-unused CSS.
Role bindings:
- Scope picker is now a list-panel filter card (matches access-review), drops
  the misuse of role=search, keeps auto-reload on scope change with an Update
  action, and moves the grants-flow explanation into the panel.
- Remove the redundant Scope column (scope is already in the header), show a
  proper empty state (no empty table), and drop emdash placeholders.
- Fix the 'Who' fieldset layout (subject type + dynamic field stacked) and add
  the user-email autocomplete used by every other binding/member form.
- Scope the 'Other roles' optgroup to roles not already in the recommended
  dropdown so admins don't see duplicates.

Roles:
- Remove the ~100-line inline <style>; move rule-chip and role-editor CSS into
  base and reuse field-group/field-grid/check-row.
- 'Create' is no longer a nav tab; it's a '+ New role' header button on the
  Built-in/Custom tabs, with a dedicated 'New custom role' view and Back action.
- Render role rules as resource/verb chips via a shared role_rules partial.
The custom-role editor's Form/YAML mode tabs now reuse the standard .tabs /
.tab classes instead of a bespoke .role-mode-tabs style, so they match every
other tab in the app (border overlap, active underline, hover). Buttons inherit
the page font via an added font:inherit on .tabs .tab.
The secret list kebab menu's Reveal item is a button, but the out-of-band
toggle swapped in after revealing (and again on hide) came from
partials/reveal_toggle.html which rendered an <a href=#>. Inside the oat
<menu> that broke the menu item styling.

render reveal_toggle as a <button type=button>; when the toggle belongs to a
list row it is now a ghost menu item (role=menuitem) that closes the kebab
popover on click, and history toggles keep the acts-cell link look via
.acts .reveal-toggle. Tests updated to assert the button markup + menu attrs.
Deleting a secret failed with 'new row violates row-level security policy
for table secrets'. can_access_secret_row() returns false for rows where
deleted_at IS NOT NULL, and the secrets_update policy only had USING, so the
soft-delete UPDATE (SET deleted_at = now()) failed the implicit WITH CHECK
for everyone, including project admins.

The secrets_select/update/delete policies now pass deleted_at=NULL (the
access gate ignores the deletion flag for the operation itself) and:
- UPDATE has an explicit WITH CHECK gated on write access, permitting
  soft-delete, restore, and edit of trash rows;
- SELECT exposes trash rows only to writers who could delete them;
- DELETE is limited to trash rows (deleted_at IS NOT NULL) by write users.

Also add the secrets_insert policy to schema.py so creates do not depend on
the init.sql bootstrap. Applies to both schema.py migrations and db/init.sql
for fresh installs. Adds a regression test for the WITH CHECK gate.
Verify the out-of-band kebab toggle renders as a ghost menu item button so a
stale or future refactor can't silently drop the menu styling like the other
items.
The kebab menu lives inside td.acts, so the link-style .acts .reveal-toggle
reset (padding:0, margin-right, color:inherit) was overriding oat's .ghost
menu-item styling on the Reveal/Hide buttons. Scope that reset to
:not(.ghost) so the history acts-cell toggle keeps its link look while the
menu items keep ghost styling like their siblings.
Users could not see what their role bindings granted them, and there was no
per-resource view of who has access and why.

- Profile: new 'My access' tab lists every binding that grants the user
  access (cluster/team/project/secret), with role, description, grant source
  (Direct vs group) and when it was created, via the new
  api.my_access_rows() SECURITY DEFINER helper.
- Project and secret Access tabs (admin/manager-gated): new 'Effective access'
  section lists who can access the resource and why, walking the scope chain
  (secret -> project -> team -> cluster), expanding groups to members, and
  appending global admins, via api.effective_access_rows().

Both helpers live in app/rbac.sql (applied by ensure_schema on boot) and
db/rbac.sql for fresh installs, and are manager-gated so the access list is
never leaked to non-admins.
The binding forms advertised 'Hover a role name for its permissions' but the
<option> elements carried no title. Load rbac.roles descriptions into a
role_descriptions map in the team, project, and role-bindings routes, and set
title attributes on the Grant dropdown options (friendly + 'Other roles'
optgroup). The secret Access-tab permission select now explains read/reveal/
write inline too.
service-read only lists/gets secret metadata (no reveal verb), so 'Read
(metadata only)' made ESO users pick a role that cannot fetch values. Relabel
to 'Read — metadata only (no plaintext)'.
The bindings breadcrumb only rendered when back_team_id was set and hardcoded
the crumb label as 'Team'. Accessing the page from the sidebar (no explicit
scope_id / no active team) left the page with no context about which team or
project you were binding within.

The breadcrumb now renders whenever a scope is selected: Teams → <team name>
→ <project or secret name> → Role bindings, using a back_team_name resolved
from the teams list, and still shows the scope context even when back_team_id
is None.
The Teams tab already queried api.team_role(t.id) AS role, but an empty role
rendered as a blank badge with no explanation. The role cell now falls back to
'—' when missing and adds a title tooltip describing what each role can do
(owner/admin/member/viewer). Locks the column + value in with test assertions.
When the bindings table is empty the page dead-ended with just text; the
add-binding form could sit below the fold on long scopes. Add a '+ Add
binding' button in the bindings section header and an 'Add the first binding'
link in the empty state, both jumping to the now-anchored add-binding section
(only when the user can edit). Regression test covers editable and read-only.
The Organisation group's team-scoped 'Role bindings' item and the global
admin's Administration 'Role bindings' item both rendered when a global admin
had an active team. Global admins now see only the Administration entry (which
defaults to their active team anyway); non-admins keep the team-scoped entry.
api.effective_access_rows() collided its OUT parameter names (scope_kind /
scope_id) with the chain CTE's output columns, so PL/pgSQL raised 'column
reference scope_kind is ambiguous' — which aborted the transaction and made
viewing a secret 500 on the reveal/audit path.

Qualify the chain CTE columns (FROM api.rbac_scope_chain(...) AS c) and use
positional ORDER BY 4, 6 instead of the shared output names. Also roll back
the transaction in the route-level try/excepts so a function error can no
longer poison the rest of the request. Applies to app/rbac.sql and db/rbac.sql.
- Secret Access tab: replace the custom email/group_id/permission form with
  the standard subject_kind + subject_email/subject_group/subject_sa +
  role_name pattern used across RBAC (fieldset/field-grid layout, JS
  User/Group/ServiceAccount toggle, ServiceAccount support for machine
  accounts). Route accepts role_name directly with legacy fallbacks.
- Merge the secret bindings table's Role and Permission columns into one
  friendly badge with the role name as a tooltip, matching the bindings panel.
- Access review page title/h1 now say 'Access review' like the sidebar.
- Teams index copy drops the pre-RBAC 'Bitwarden-style' wording.
- Projects list link renamed 'Manage members' -> 'Manage access'.
- Remove leftover blank lines in project/team Access tab links.
- Team Members table header 'User' -> 'Member'.
- Role-bindings scope picker: remove the redundant Update button now that
  the scope_id selects auto-submit.
- Rename DB column api.secrets.acl_mode -> access_mode everywhere (schema,
  rbac.sql x2, init.sql, routes, templates, tests); template vars acl_* ->
  access_*.
- Remove SECRET_ACCESS_* and PROJECT_ROLES config; routes validate against
  RBAC_*_ROLE_DROPDOWN (team/project/secret/management APIs).
- rbac_sync.py accepts RBAC role names directly (short owner/member/... still
  normalized for invites/join-requests/LDAP-OIDC maps); audit emits full
  rbac.roles names.
- dir_sync.py uses RBAC role priority (no ROLE_RANK).
- Secret Access tab reuses access_bindings_panel.html and posts role_name
  (secret-* roles); service accounts are bindable at secret scope.
- Rename team/project *member routes -> *binding.
- schema.py: drop api.groups.team_role column, project_group_roles, _role_rank
  and the legacy RBAC function definitions (rbac.sql is sole source; fresh DB
  boots via db/rbac.sql).
- Clean stale legacy comments in rbac.sql.

Database is deployed fresh (no migration path).
Physically delete the pre-RBAC function definitions still listed in the
initial statements: api.is_team_member, api.team_role, api.can_read_project,
api.can_write_project, api.can_admin_project, api.can_access_secret_row
(including the writers/admins/owners mode variant) and api.can_access_secret.
These were only kept alive by the legacy_markers runtime filter and would have
queried dropped tables (api.team_members / project_members / project_group_roles)
if that filter ever regressed. rbac.sql is now the sole source for RBAC
functions; grants for the rbac.sql-backed names are retained.
Update the dev seed script to the current RBAC model:
- Use access_mode (no legacy acl_mode) and drop the removed api.groups.team_role
  column writes.
- Add custom roles (secrets-operator, audit-reader, infra-viewer,
  payments-reviewer) created idempotently with rules, and bind them to users,
  groups, and secrets to exercise the Roles page, Access review, Effective
  access, and custom-role dropdowns.
- Add machine accounts (ServiceAccount subjects) with key allow-lists bound at
  project scope, plus ServiceAccount rows in effective access.
- Add reveal-approval secrets and restricted-mode secrets via secret-scope
  bindings. Bind helpers are fully idempotent (clear subject scope bindings).
- Seed test asserts the new symbols and that acl_mode is gone.
Critical:
- secret_ops._parse_access_mode maps legacy 'custom' -> restricted (was falling
  back to inherit, which broadened access); regression test added.

Role vocabulary (RBAC names everywhere):
- LDAP/OIDC team maps, team invites, and team join requests now store and
  validate rbac.roles names (team-admin/member/viewer); invites exclude owner.
  CHECK constraints updated, team.html selects use team_role_dropdown.
- Remove TEAM_ROLES and the INVITE_ROLES short vocabulary (now RBAC names);
  MACHINE_TOKEN_ROLES kept (machine_tokens.role read/reveal/write domain).
- transfer_ownership binds team-owner/team-admin directly.
- dir_sync uses stored RBAC names + RBAC priority (no TEAM_MAP_ROLE_TO_RBAC).
- rbac_sync drops TEAM_SHORT_ROLES/PROJECT_SHORT_ROLES shims; sync functions
  require rbac role names directly.

Dead code / terminology:
- add_secret_access_binding drops legacy email/group_id/permission fallback.
- can_reveal_acl -> can_reveal_perm.
- rbac.sql can_access_secret_row only handles inherit/restricted (no custom /
  writers/admins/owners); stale comments removed.
- schema.py access_mode CHECK allows only inherit/restricted; legacy custom ->
  restricted migrations removed.

Already clean (verified): legacy RBAC function definitions and
project_group_roles/groups.team_role were removed earlier.
- totp_svc: drop pyotp; TOTP now uses stdlib hmac/hashlib/struct/time
  (new_secret, provisioning_uri, verify_code). Remove pyotp from requirements.
- passwords/pats: inline hash_token/_hash wrappers; call crypto.sha256_hex.
- schema.py: remove obsolete legacy role-value conversions (read-only ->
  viewer/reveal), conflicting invite/join short-role constraint remounts, the
  kind-backfill migration and its legacy note-tag parsers.
- secret_kinds: delete dead kind_from_legacy_note/strip_legacy_type_tags.
- auth: drop the _profile_url() wrapper (url_for directly) and the oidc_auth
  __main__ self-check; user_sessions: drop the _client_meta alias.
- project_io: iterate import form getlists by index (no pad()/zip).
- audit: describe_event via a formatter dict. nav: delegate team-banner
  normalization to settings_svc.team_classification().
- keep MACHINE_TOKEN_ROLES (machine_tokens.role domain); describe_event/
  parse_rules_yaml/member-row helpers were already stdlib-shrunk.
mgmt_add_team_binding validated role against RBAC_TEAM_ROLE_DROPDOWN names
(team-owner/...), but the owner guard compared role == "owner" — never true,
so any team admin could self-promote a user to owner via /eso/v1/teams/<ref>/members.
Compare against "team-owner" like teams.py:445, and add a regression test
that asserts a non-owner admin gets 403 when requesting team-owner.
- mgmt_api: enforce can_manage_rbac in team member add/remove and transfer
  routes; pass RBAC role names (team-owner/team-admin) in transfer route.
- projects.py: fix project_access_binding_create variable references
  (subject_group instead of undefined group_id) and call
  sync_*_project_binding directly using role_name.
- teams.py: map legacy short roles in approve_join_request so pending requests
  created under older code still resolve correctly to team-member.
- rbac_sync: export public role_id(cur, name) function (replaces _role_id).
api.groups no longer has team_role (roles live in rbac.bindings). Remove
broken g.team_role usage from init.sql helpers and a corrupt schema.py
statement fragment that crashed ensure_schema on first request.
- Remove legacy tables: team_members, project_members, project_group_roles
- Remove legacy functions: _role_rank, _perm_rank, is_team_member, team_role,
  project_role, can_read_project, can_write_project, can_admin_project,
  can_access_secret_row, can_access_secret, guard_last_team_owner
- Remove legacy triggers and RLS policies on legacy tables
- team_role() now returns RBAC names (team-owner, team-admin, team-member, team-viewer)
- project_role() now returns RBAC names (project-admin, project-write, project-read)
- can_manage_rbac() updated to use RBAC role names
- Machine token roles renamed to service-read, service-reveal, service-write
- Rename acl_restricted to access_restricted in secret_ops and templates
- Rename acl parameter to access_mode in secrets route and templates
- Remove legacy custom access mode handling from _parse_access_mode
- Update all RLS policies to use RBAC role names
- Update CHECK constraints on team_ldap_maps, team_oidc_maps, team_invites,
  team_join_requests to use RBAC role names
- Add private.team_member_rows, project_member_rows, project_group_role_rows
  to rbac.sql (query rbac.bindings instead of legacy tables)
- Add machine token role migration for existing databases
- Add legacy role value migration for existing databases
- Update all Python routes to use RBAC role names
- Update all tests to match changes
- team.html: Update 5 my_role comparisons from legacy role names
  ('owner'/'admin'/'member') to RBAC names ('team-owner'/'team-admin'/'team-member')
- profile.html: Update role_help dictionary keys to RBAC role names
- test_teams.py: Update mock data and form roles to RBAC names
  (test_list_teams, test_add_member_denied, test_delete_team_owner_ok,
  test_delete_team_non_owner_denied)
- test_secrets.py: Update mock role 'viewer' to 'team-viewer'
- test_ldap.py: Update form role 'member' to 'team-member' (2 occurrences)
- test_paging.py: Fix acl= param to access_mode= (would cause TypeError)
- test_org_access.py: Update 7 assertions to check rbac.sql instead of
  init.sql for functions/policies that were moved (guard_last_team_owner,
  can_admin_project RLS, can_access_secret_row, can_reveal_secret,
  CREATE POLICY secrets_*)
Database fix:
- Convert 5 LANGUAGE sql functions in init.sql and schema.py to
  LANGUAGE plpgsql so PostgreSQL defers body validation:
  team_group_rows, group_member_rows, secret_meta_rows,
  secret_access_request_rows, pending_access_requests_for_admin
- These functions call RBAC auth helpers (is_team_member,
  can_access_secret, can_admin_project) defined in rbac.sql

Documentation refactor:
- All docs updated to RBAC role names (team-owner/team-admin/
  team-member/team-viewer, project-admin/project-write/project-read,
  service-read/service-reveal/service-write)
- acl_mode -> access_mode, removed legacy custom/writers/admins/owners
- Updated config constants (RBAC_TEAM_ROLE_DROPDOWN, ACCESS_MODES, etc.)
- database.md: init.sql/rbac.sql split, RBAC tables, LANGUAGE plpgsql note
- architecture.md: rbac_sync.py, db/rbac.sql, service-* roles
- api.md: access_mode, service-* roles, CLI examples with RBAC names
- testing.md: complete test list, rbac.sql assertion notes
- contributing.md: 4-file sync instructions
- deploy.md: layout table with init.sql + rbac.sql
- SECURITY.md: per-secret access mode

Python docstrings:
- rbac_sync.py: Args + Example for sync_*_binding functions
- schema.py: expanded _apply_rbac_sql docstring
Use service-reveal in MACHINE_TOKENS and drop legacy short names from
SERVICE_ROLE_MAP so mock seed works on a fresh k8s-rbac database.
- Add structured comments to 7 remaining init.sql functions:
  register_user, verify_user, change_password, set_local_password,
  upsert_ldap_user, upsert_oidc_user, glob_to_like
- Add matching comments to app/schema.py SQL migration strings
- Fix typo in glob_to_like comment
- Verify db/rbac.sql and app/rbac.sql comments are in sync
Trim verbose hint text, empty states, and help paragraphs on the
secret access/metadata, project, RBAC, settings, team, profile,
history, import, token-create, and TOTP-setup screens. No behavior
changes — user-facing copy only.
Only inherit|restricted remain. ensure_schema rewrites custom→restricted
and other legacy values→inherit, drops acl_mode columns/secret_acl, and
tests/docs match (no form alias for custom).
- Add Input/Output/Example comments to 7 remaining init.sql functions:
  register_user, verify_user, change_password, set_local_password,
  upsert_ldap_user, upsert_oidc_user, glob_to_like
- Add matching comments to app/schema.py SQL migration strings
- Fix typo in glob_to_like comment
- Move Create secret button to section-head (top right, RBAC style)
- Move search and bulk actions to section-tools below heading
- Collapse quick-add form into <details> toggle (hidden by default)
- Move search and bulk actions into secrets-toolbar directly above table
- Add CSS for .quick-add summary (dashed pill, rotating chevron)
- Add CSS for .secrets-toolbar (flex row, search left, bulk right)
- Keep section-head clean: h2 + Create secret button (RBAC style)
Remove quick-add from project secrets list
Some checks failed
CI / tox py + lint (pull_request) Has been cancelled
aac204f5f8
Drop the inline quick-add form and its CSS. Keep Create secret on the
right, and show search/bulk only when the project has secrets (or an
active search) so empty projects stay uncluttered.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Sigaint/corvus!8
No description provided.