Users, profiles and permissions
Introduction
Access control in Dédalo answers two separate questions for every request: "who are you?" (authentication) and "what may you do?" (authorization). The two are handled by two different classes and this guide ties them together into the everyday administration workflow: how to create a user, how a profile turns into a per-element permission map, and how those permissions are computed and enforced on every read and save.
This is a workflow page. The exact mechanics live in the reference docs and are not repeated here at length — read them alongside this guide:
- security — the authorization core (resolves and enforces the 0–3 levels).
- login — authentication: credentials, session, the users section.
- component_security_access — the stored per-profile permission matrix.
- API gate — where the login check, CSRF and permission gates run at the request boundary.
The mechanics below live in src/core/security/permissions.ts (the matrix +
getPermissions), src/core/security/auth.ts (login, Argon2id via
Bun.password) and src/core/security/session_store.ts (session
issuance/rotation).
The Dédalo way: no bespoke tables
There is no users table and no permissions table. Users are ordinary
records in the Users section, profiles are ordinary records in the
Profiles section, and a profile's permission map is an ordinary
component datum. Everything is versioned in Time Machine, importable and
propagable like any other data.
The four permission levels
A permission is a single integer; a higher level always includes the lower ones.
| value | level | meaning |
|---|---|---|
0 |
no access | the element is not returned and may not be read |
1 |
read only | read, but every write is refused |
2 |
read / write | read and save |
3 |
admin | full control (structure edits, debug) |
The absence of a stored row means level 0. Rows whose value is 0 are never
persisted — the editor builds a full tree for the UI but strips every
value <= 0 on save, so "no access" is simply the absence of a grant.
See Components — Permissions for the levels from a component's point of view.
Where users and profiles live
| concept | section | tipo | notes |
|---|---|---|---|
| User | Users | dd128 (DEDALO_SECTION_USERS_TIPO) |
login name, password, active flag, profile, projects |
| Profile | Profiles | dd234 (DEDALO_SECTION_PROFILES_TIPO) |
carries the component_security_access matrix (dd774) |
A user record links to a profile through its DEDALO_USER_PROFILE_TIPO
(dd1725) component, and to one or more projects through its
component_filter_master (dd170). The profile, in turn, owns the permissions
matrix (component_security_access, tipo dd774). When the user logs in, that
matrix is flattened into the fast lookup table that gates the rest of the
session — on the TS server this is getPermissionsTable() in
src/core/security/permissions.ts, cached per user_id in a module Map
(see the cache warning below).
flowchart LR
U["User record<br/>(Users section dd128)"] -->|"DEDALO_USER_PROFILE_TIPO (dd1725)"| P["Profile record<br/>(Profiles section dd234)"]
P -->|"component_security_access (dd774)"| M["permission matrix<br/>per-element 0–3"]
U -->|"component_filter_master"| PR["projects<br/>(per-record scope, layer 2)"]
The super-user root (section_id = -1) is outside this model: it is always a
global admin, bypasses the profile/projects checks, and cannot be managed from
the web interface (see root user and
changing the root password).
Two roles above the profile
Two flags promote a user beyond their profile's grants. They are stored on the user record and read back from the session for the current user:
- Global admin (
DEDALO_SECURITY_ADMINISTRATOR_TIPO,dd244) — bypasses the per-record project scope and unlocks the admin-only surfaces (the Maintenance area, Time Machine reads, structural operations). It does not bypass the per-element permission matrix: an admin-flagged user still resolves each element through their profile's grants — only the superuserrootresolves level3everywhere. Give an admin's profile the grants you expect them to have. This is the "general admin" account described in the management index. It is theisGlobalAdminflag on the request'sPrincipal, resolved byresolvePrincipal()insrc/core/security/permissions.ts. - Developer (
DEDALO_USER_DEVELOPER_TIPO,dd515) — grants access to development/structure surfaces; theisDeveloperflag on the samePrincipal.
Both, plus a profile that grants the maintenance area, are required to use the Maintenance panel.
Workflow: create a profile
- Go to the Profiles section (under System administration).
- Create a new record and name it (e.g. Cataloguer, Read-only reviewer).
- Open the Permissions component (
component_security_access). It renders the whole ontology as an expandable tree — areas → sections → elements — with four radio buttons per node for the levels 0–3. - Set the level on each node you want to grant. Areas and sections derive their level from their children (a parent shows a level only when all its children share it), so you normally set leaf elements (components, buttons) and let the parents follow. Selecting a value propagates up the parent chain.
- Save. Only non-zero rows are written; absent rows mean no access.
See component_security_access for the tree shape, the structural exclusions (the Admin area and its children are never shown for permissioning), and the per-profile data format.
The component_security_access descriptor
(src/core/components/component_security_access/descriptor.ts) serves the
dd774 datum for both the editor and read/authorization purposes
(getPermissionsTable() in permissions.ts, plus the datalist resolver in
src/core/resolve/security_access_datalist.ts). The tree editor itself —
steps 3–4 above — lives in client/dedalo/core/component_security_access/.
Workflow: create a user
- Go to the Users section.
- Create a new record and fill the login name (
dd132) and password (dd133; min length 8 — see the gap noted below). - Set Active account (
dd131) to Yes — an inactive account is refused at login. - Assign a profile (
DEDALO_USER_PROFILE_TIPO). A user with no profile is refused at login (User without profile). - Assign at least one project (
component_filter_master). A user with no project is refused at login (User without projects) and, at runtime, sees only the records inside their projects (layer-2 scope, below). - Optionally promote to global admin and/or developer (above).
Gap: password hashing on save is not implemented
The component_password descriptor
(src/core/components/component_password/descriptor.ts) is currently a
plain string column with no save-time hashing hook — the server can only
verify an existing $argon2id$… hash at login
(src/core/security/auth.ts); it cannot create one through the
ordinary Users-section save path yet. Until this is implemented, create or
reset a user's password by setting the hash directly in SQL, the way
changing the root password describes for
root — the same matrix_users row, the same dd133 component, keyed by
that user's section_id.
Who can create whom
Per the management index, root creates the first general admin; general admins can then create other admins, developers and ordinary users and assign their profiles/projects — but an account cannot edit its own admin/developer configuration.
How permissions are computed and enforced
All authorization is resolved and enforced server-side, never trusting the client. There are two independent tiers:
- Type / schema permission (layer 1). "What may this profile do with this
section or component type?" — resolved from the flattened permission table.
A single function reproduces the whole decision order —
getPermissions(principal, parentTipo, tipo)insrc/core/security/permissions.ts— including the Time-Machine admin-only clamp, the superuser/tools-register/temp-preset shortcuts, the maintenance area block, and the public list/dd/notes read fallback. Resolves to0when not logged in. - Per-record / project scope (layer 2). "Is this specific record inside the
user's project scope?" —
getUserProjects(userId)(same file) resolves the user'sdd170project locators, andsrc/core/search/sql_assembler.tsfolds them into the generatedWHEREclause every list/search applies.
The level is resolved in one place but checked at several chokepoints: each
dispatch entry point calls getPermissions/getUserProjects inline at its own
gate.
- Read —
src/core/api/dispatch.tsresolves a read permission (level ≥ 1) per action before serving it. Inside the section read (src/core/section/read.ts), each element the user holds level0on is dropped from the response entirely (context and data — its value never leaves the server), and every served element's context carries its real level. The client renders from that stamp:1draws the component read-only,≥ 2editable — so a user without a write grant never gets an editable field that would only fail at save time. - Create / save — the save paths in
dispatch.tsandsrc/core/relations/save.tsrefuse whengetPermissions(...) < 2. - API gates — tool actions gate inline in
src/core/tools/security.ts(project-scope check) anddispatch.ts(section-permission check). A failed gate returns the same uniform{result:false, errors:[...]}shape from an earlyreturn— there is no separate exception type.
The full enforcement diagram and method list are in security.
Invalidate the cache after editing permissions
The permission table is cached in a single per-user_id module Map
(permissionsTableCache in permissions.ts) — after editing a profile's
permissions, the next request must see the new matrix. Call
clearPermissionsCache(userId) (or with no argument, to clear every user)
after a profile's dd774 data or a user's profile assignment changes; the
companion clearUserProjectsCache() does the same for layer-2 project
grants. The server is request-scoped via AsyncLocalStorage (see
engineering/REWRITE_SPEC.md), so there is no cross-request identity bleed to
worry about — but these two caches are long-lived across requests by
design and still need explicit invalidation.
The no-login allowlist
Authentication is enforced at the API gate for every
action except a small allowlist of bootstrap and machine-to-machine
actions that must run before a session exists. That list is
NO_LOGIN_ACTIONS in src/core/api/dispatch.ts: login, get_environment,
start, get_login_context, error-report intake from a remote install
(receive_report), and the ontology-/code-master reachability and manifest
actions (get_server_ready_status, get_ontology_update_info,
get_code_update_info) — the last three fail closed unless this install is
configured as an ontology or code server (see Updates).
change_lang is deliberately not in the allowlist, so it requires a
logged-in session; see Request-scoped langs for
why. Everything else requires a valid session
(context.session !== null); the action name is compared as a strict string
against the allowlist, looked up by exact key in a Record.
Worked example: a read-only profile for one section
Goal: a profile that can read the Archaeological objects section (say
rsc197) and its fields, but cannot edit anything, and has no access to the rest.
- Create the profile. Profiles section → new record → name it Objects reader.
- Open Permissions (
component_security_access) and expand the tree to the Archaeological objects section. - Set the section to level
1(read only). Set the section node — and its description fields — to1. Because parents derive from children, set the leaf components to1and the section node follows. Leave everything outside that section untouched (absent =0= no access). - (Optional) grant
1on linked Thesaurus sections so the reader can see the controlled terms referenced by the records, but not edit the vocabulary. - Do not grant the
button_newof the section — without write (2) on the section the create/save path insrc/core/api/dispatch.tsrefuses new records anyway, and the thesaurus tree's own button-level check isgetPermissionsElement(sectionTipo, 'button_new', principal)insrc/core/ts_object/ts_object.ts. -
Save. Only the non-zero rows persist, e.g.:
[ {"id": 1, "tipo": "rsc197", "section_tipo": "rsc197", "value": 1}, {"id": 2, "tipo": "rsc85", "section_tipo": "rsc197", "value": 1} ] -
Assign the profile to a user (Users section → user → profile = Objects reader), with a project so layer-2 scope is satisfied.
- Verify. Log in as that user: the Archaeological objects records render
read-only (the served context stamps level
1, so the client draws values without inputs or tool buttons), any write is refused server-side (< 2), and other sections and ungranted components do not appear at all (0— dropped from the response, values included). The user only sees records inside their assigned projects.
Programmatic grant (dev)
To grant a profile read+write over a list of sections and all their
children in code — e.g. when generating hierarchies — call
setSectionPermissions() (src/core/security/section_permissions.ts).
Default level 2, 0 accepted; it merges into the existing matrix (an
existing (tipo, section_tipo) pair is updated in place, never duplicated)
and invalidates the permissions table.
The grant lands on the user's profile record (dd234), resolved through
their profile-select (dd1725) — never on the user record. A user with no
profile assigned cannot be granted anything: the call returns ok:false
with an error rather than throwing, and callers decide whether that is
fatal (hierarchy provisioning treats it as non-fatal).
This is what makes a freshly provisioned hierarchy visible to the user who created it — see install new hierarchies.
Related
- security — authorization core, the 0–3 levels, the enforcement gates and caching.
- login — authentication, the session
authblock, the users section as credential store. - component_security_access — the stored per-profile permission matrix and its editor.
- API gate — the request-boundary gates (login, CSRF, permission).
- Management index — root vs general admin, the maintenance panel, the access policy in prose.
- Installing new hierarchies — setting permissions on freshly created sections.