The single source of truth for how Portal's security model works, every knob it exposes, and what an operator does to manage it. Reading this once should leave you able to provision users, lock down paths, scope operator visibility, federate two nodes, and reason about what the system does and does not protect against.
When you find this doc out of sync with the code, the code wins — update this doc, don't reverse it. (Law 1, Law 5.)
Portal has one identity model and one authorization model, and both apply uniformly to every interface — local CLI, HTTP/HTTPS REST, SSH, and federation between nodes.
- Identity is a Portal user record (username + password + api_key
- labels). A caller proves identity by presenting a password, a
session token from
/auth/login, or an api_key. On federation the proof rides as the api_key inctx->auth.tokenand is re-authenticated against the receiving node's local registry — no cross-node trust, no shared session table.
- labels). A caller proves identity by presenting a password, a
session token from
- Authorization is a label intersection: every resource path
carries zero or more labels, every user carries zero or more labels;
if either side is empty, access is open; otherwise the user must
carry at least one of the path's labels (Law 8). A second filter
drops emitted rows whose own labels don't intersect the caller's
(Law 15). User
rootand thesys.see_alllabel bypass both layers — the bypass is audited.
Everything below is detail.
Defined in include/portal/types.h and src/core/core_auth.h:
typedef struct {
char username[PORTAL_MAX_LABEL_LEN]; /* unique */
char password[256]; /* "$sha256$salt$hash" or plain */
char api_key[AUTH_KEY_LEN + 1]; /* 64 hex chars */
portal_labels_t labels; /* up to PORTAL_MAX_LABELS */
} auth_user_t;There is no separate "group" object — a group is just a label that one
or more users carry. /groups/<n>/add adds the label <n> to the
user; /groups/<n>/remove strips it.
| Method | Credential | Carried as | Where you'd use it |
|---|---|---|---|
| Password | username + password |
POST /auth/login returns a session token |
Interactive logins, web forms |
| Session token | 64-hex returned by /auth/login |
Authorization: Bearer … or X-Auth-Token: … |
Browser sessions, scripted calls after login |
| API key | 64-hex per user, lifetime = user record | X-API-Key: … header (HTTP), ctx->auth.token (federation) |
Service-to-service, federation peer identity |
API keys are first-class — every user has one, generated on creation
or rotation. Issue: /auth/key/rotate?user=<n>. Read: /auth/key
(returns the calling user's own key only).
- Default seed —
users.confnext toportal.conflists initial users. Format:username:password:label1,label2:api_key.api_keymay beauto(generated on first start) or empty (generated on first use). Per-instance file lives at/etc/portal/<inst>/users.conf. - Runtime create —
PUT /users/<name>withpassword+groupsheaders. Persisted to the storage backend (sqlite or psql) and the KV store. Admin-only since Phase 3 (see §6.2). - Module auto-provisioning — modules with a need (e.g. mod_ssip_hub
creating per-carrier
dev-root-<N>users) callcore->auth_ensure_user(core, username, labels, key, …). Thekeyargument is NULL for "generate random" or a string for "use this exact api_key". Persists through the same dual-write path. See §6 for the full API.
Two reserved usernames exist by default: root (full bypass at the
ACL layer) and admin (no bypass, but seeded with the admin label).
Every path registered with core->path_register(core, path, module)
gets:
- An access mode —
PORTAL_ACCESS_READ,WRITE, orRW— set viacore->path_set_access. Currently informational (used by documentation generators and the CLI help), not enforced at dispatch. - Zero or more labels — added via
core->path_add_label.
At dispatch time, portal_path_check_access (src/core/core_path.c:128)
runs:
- Path not in registry → deny.
- Caller is user
root→ allow (the only username-based bypass). - Path has no labels → allow.
- Caller has no context or no user → deny.
- Otherwise →
portal_labels_intersects(path.labels, caller.labels).
Match the rules in your head before adding a label, because they short-circuit in this order.
These labels have agreed meaning across the system. Anything else is free for module/operator use.
| Label | Carried by | Meaning |
|---|---|---|
root |
(paths) the default mod_shell.access_label; unrelated to the user root which bypasses regardless |
Required to call /shell/functions/* unless the caller is user root |
admin |
seeded admin user |
Generic admin gate; modules that want a single admin label use this |
hub-admin |
hub operators (Phase 3) | Required to call mutation endpoints /users/, /groups/, /core/modules/, /core/config/set |
hub-operator |
hub operators (recommended convention) | Read-only operator access; not currently enforced by core |
sys.see_all |
supervisors | Bypasses Law 15 row filtering (audited via /events/acl/bypass) |
ssip-device |
per-carrier dev-root-<N> user |
Marks "this caller is a federated SSIP device, not an operator" |
ssip<N> |
per-carrier dev-root-<N> user AND operators of carrier N |
Carrier scope for the SSIP fleet (Law 15 filter on the updates dashboard, plus any other label-keyed policy) |
If you invent a new label, document it here.
Where Law 8 asks "can you call this path?", Law 15 asks "of the rows this path returns, which ones do you see?". Both checks fire; either can deny.
core->labels_allow(core, ctx, row_labels) (include/portal/core.h,
backed by portal_labels_allow in core_path.c):
ctx == NULL→ allow (internal call).ctx->auth.user == "root"→ allow (no bypass event — root is the built-in).ctxhas labelsys.see_all→ allow AND emit/events/acl/bypasswith bodyuser=<name>.row_labelsis NULL or empty → allow (public row).- Otherwise → label intersection.
Any handler that lists rows: if (!core->labels_allow(core, msg->ctx, &row_labels)) continue; See mod_node.c:/node/resources/peers for
the worked example or docs/MODULE_GUIDE.md §"Filtering row output".
If a caller asks for a specific row by name (a detail handler, not a list), and the filter denies, the response is the same "not found" the handler would return for a row that genuinely doesn't exist. Distinguishing "hidden" from "absent" turns the filter into an enumeration sidechannel — don't.
Portal nodes peer over TLS-PORTAL02 (mod_node). Once the handshake completes, the two peers run a small bidirectional identity_proof exchange that resolves each side's claimed key against the other side's local user registry. Subsequent inbound messages from that peer are dispatched as the resolved local user (or anonymous if no key matched). The legacy "promote every federated message to local root" compat path was removed in Phase 5.
- Initiator's
mod_node, post-handshake, sendsCALL /<peer>/node/functions/identity_proofwith headerkey=<our outbound key for this peer>. - Responder's handler validates the key via
core->auth_find_by_key. On match, stores the resolved user (name + labels) on the peer struct and replies 200 with its outbound key for the same peer in the response body. On miss, returns 401 with no body — strict initiator-first ordering ensures we don't reveal our key to a stranger. - Initiator validates the body the same way and stores the result.
- From this point, the per-peer state drives
ctx->authfor every inbound message. No further key exchange occurs on that connection.
federation_key = <shared secret> # mesh membership only
peer_default_key = <hex> # OUR outbound key for any unlisted peer
peer_keys = <peer1>:<hex>, <peer2>:<hex>
federation_keygates mesh membership at the PORTAL02 handshake. Knowing it grants no user-level privileges.peer_keysis the per-peer outbound key map. Each<hex>must match the api_key of a Portal user on the destination node.peer_default_keyis the fallback used for any peer not inpeer_keys. For multi-tenant deployments leave it empty so a compromise of one peer's key reveals only that peer's identity.- A runtime mutation API exists at
/node/functions/set_peer_key(admin-or-internal gated) so modules likemod_ssip_hubcan populate keys without restart. federation_inbound_default_user = <username>— opt-in escape hatch added 2026-04-19. When set, an inbound federation peer that did not complete identity_proof (anonymous from our perspective) is stamped as this local user instead of NULL. The user must exist locally; its labels are cached at module load. Never set on multi-peer nodes — any peer that knowsfederation_keywould inherit this user's privileges. Designed for SSIP devices: a single trusted peer (the hub), andpeer_keys-based identity is hard to bootstrap on dev boxes (root = 0has no carrier-bot) or after truncation effects (api_keyfield is 64 hex but/etc/ssippwdis 128 hex SHA-512). On a device,federation_inbound_default_user = hubmakes operator shell from the hub work even when identity exchange degrades. Hub-side stays strict.
Inbound from a peer with no matching key in our registry → ctx is cleared (user NULL, no labels). Downstream Law 8 denies labeled paths; Law 15 denies labeled rows. The peer is alive on the wire but useful only for explicitly open endpoints.
For SSIP devices that registered before Phase 2a shipped: the hub
exposes /ssip/hub/functions/get_carrier_key (auth: device's existing
128-hex password header). On boot, mod_ssip notices the missing
peer_keys line, calls the endpoint, writes the key, _exit(0)s for
systemd to respawn portal. Idempotent across reboots, silent skip on
older hubs (404).
- Defends: a compromised federation peer key can no longer be used as a master key to act as root on every other peer. Each peer is locally bounded to whatever identity its key resolves to, and the Phase 3 gate (§6.2) prevents low-priv identities from mutating anything.
- Doesn't defend: a peer that knows the shared
federation_keycan still complete the PORTAL02 handshake and JOIN the mesh. Identity exchange comes after; without provisionedpeer_keysan attacker on the mesh just becomes anonymous to everyone — annoying but bounded. Treat thefederation_keyas "you can talk to us at all"; treat per-peer keys as identity.
Several core paths are dispatched via the prefix router and were historically not registered in the path tree with labels — the Law 8 ACL silently skipped them. This was harmless under the legacy compat "promote to root" path, but under strict identity a low-privilege federation peer could otherwise mutate users, groups, modules, or config because it would still pass the empty-label check.
core_handlers.c:caller_is_admin() closes the gap explicitly:
- User
root→ allow. - Caller has label
hub-admin→ allow. - Otherwise → 403 FORBIDDEN.
| Path | Method | Notes |
|---|---|---|
/users/<name> |
SET | Create/update user |
/users/<name>/password |
CALL | Self-exception: the user themselves can change their own password without hub-admin |
/groups/<name> |
SET | Create group |
/groups/<name>/add |
CALL | Add user to group |
/groups/<name>/remove |
CALL | Remove user from group |
/core/modules/<name> |
CALL | Load/unload/reload module |
/core/config/set |
(any) | Mutate any module config value |
/ssip/functions/set_hub_key (devices) |
CALL | Hub-pushed re-key on root reassignment. Auth: caller's resolved local user must be hub (Phase 2b). The hub-admin label on the path is belt-and-braces — both must hold. |
GET /users, GET /users/<n>, GET /groups, GET /groups/<n>,
GET /core/config/get, GET /core/modules, GET /core/paths —
any authenticated caller. Anonymous callers (federation peers without
a matching key) still cannot reach them in practice because the
prefix router falls through to deny if the dispatch handler doesn't
know what to do, but if you want explicit denial for anonymous
callers, label the relevant paths.
int (*auth_find_by_key)(portal_core_t *core, const char *api_key,
char *out_username, size_t out_username_sz,
portal_labels_t *out_labels);
int (*auth_find_user)(portal_core_t *core, const char *username,
portal_labels_t *out_labels);
int (*auth_ensure_user)(portal_core_t *core,
const char *username,
const portal_labels_t *labels,
const char *key,
char *out_key, size_t out_key_sz);
int (*labels_allow)(portal_core_t *core,
const portal_ctx_t *ctx,
const portal_labels_t *row_labels);All in include/portal/core.h. Pure reads except auth_ensure_user,
which dual-writes (storage + KV).
Per-instance settings in [mod_node]:
tls = true|false # required for federation in production
cert_file = /etc/portal/<inst>/certs/server.crt
key_file = /etc/portal/<inst>/certs/server.key
tls_verify = true|false # require valid peer cert chain
The default tls_verify = false accepts self-signed certs — useful in
small meshes; flip to true in production where you have a CA. The
identity exchange (§5) is entirely independent of this — both layers
should be on.
[mod_web]
port = 8080 # plain HTTP; set 0 to disable
tls_port = 8443 # HTTPS; set 0 to disable
cert_file = …
key_file = …
When both are non-zero, both listen. Clients should use HTTPS. The
plain HTTP port is acceptable for in-cluster localhost-only
deployments; anywhere else, set port = 0.
[mod_ssh]
port = 2220 + (web_port - 8080) # auto-derived per instance
host_key = /etc/portal/<inst>/certs/ssh_host_key
mod_ssh accepts username + password (no key-based auth at present).
Authenticated session is bridged to the local mod_cli socket — same
ACL as a direct CLI session.
Known issue (HIGH severity, audit findings open) —
mod_ssh's host-key generation callssystem("ssh-keygen ... -f %s ...", g_host_key)whereg_host_keyis config-controlled. Unsanitised path with shell metacharacters → command injection. Track in security debt; do not sethost_keyfrom untrusted config.
mod_audit records every request and every event-system emission to a
circular in-memory buffer (default 10 000 entries) and optionally
appends to a log file (audit_file = path in mod_audit.conf). The
file is opened in append mode but is plaintext and clearable by
anyone with the admin label via /audit/functions/clear.
/events/acl/bypass (emitted by Law 15's sys.see_all rule) is one
of the events recorded. Other auth-relevant events:
/events/auth/login(login success/failure — emitted bycore_auth)/events/auth/key_rotated(api_key regeneration)- Module-specific:
/events/ssip/hub/register_approved, etc.
- Not tamper-evident — no HMAC chain.
- Not rotated automatically (operator manages logrotate).
- The federation identity exchange itself does NOT yet emit an audit event. (Tracked as small follow-up dev work; see §13.)
# On the hub, attach to CLI as root
portal -n hub -r
portal:/> login root <root password>
# Create the user with an initial password
portal:/> user create maria <s3cretpass>
# Grant carrier visibility (Law 15 scopes the SSIP updates dashboard)
portal:/> group adduser ssip5 maria
# Grant operator role (currently informational; reserved for future use)
portal:/> group adduser hub-operator maria
# If she also needs to mutate users/groups/modules/config:
portal:/> group adduser hub-admin mariaMaria can now log in to the hub via HTTP/CLI/SSH with her password,
see only root-5 device updates, and (if granted hub-admin) create
more users.
portal:/> user create supervisor <s3cretpass>
portal:/> group adduser sys.see_all supervisor
portal:/> group adduser hub-admin supervisorEvery Law 15 row this user inspects emits /events/acl/bypass —
visible in mod_audit's log + queryable via get /audit/resources/log. Use sparingly.
# Via CLI (the rotated user receives the new key once; cannot re-fetch)
portal:/> key <username>
# Via HTTP (admin only)
curl -X CALL -H "X-Auth-Token: $ADMIN_TOK" \
http://host:8080/api/auth/key/rotate?user=<username>If the rotated user is one referenced by another node's peer_keys,
that peer's mod_node.conf must be updated and mod_node must be
re-init'd or the runtime API /node/functions/set_peer_key called.
On each side:
- Pick a unique
node_nameand a sharedfederation_key(paste the same hex in bothmod_node.conffiles). - Configure the dialer side's peer entry in the
[nodes]section:peer0 = <other-name>=<host>:<port>. - Create a Portal user on each node that the other side will
authenticate as (e.g. on node A create user
peer-from-B, note its api_key; on node B create userpeer-from-A, note its api_key). - On node A
mod_node.conf:peer_keys = B:<peer-from-A's api_key>. - On node B
mod_node.conf:peer_keys = A:<peer-from-B's api_key>. - Restart both. The log line
Identity exchange ok with peer 'X' → local user 'Y'confirms success on both sides.
Symptom: journalctl … grep "Peer .* returned key that does not match any local user" or Identity exchange with .* failed (status=401).
Cause: outbound peer_keys entry on one side doesn't match an
api_key in the other side's user registry. Re-derive the key via
auth.key on the destination, edit peer_keys on the source,
restart (or call set_peer_key for runtime).
When an operator changes ssip_devices.root for some device id, the
device must start authenticating to the hub as the new carrier-bot
user (e.g. dev-root-19 instead of dev-root-5). The mechanism is
PG-trigger-driven and automatic.
-- One-time DBA setup (per Law 1 the operator executes; mod_ssip_hub
-- never modifies tucall schema). Adds a trigger that fires when
-- ssip_devices.root changes and pings the hub via the existing
-- send_udp() helper that mod_ssip_hub's listener already understands.
CREATE OR REPLACE FUNCTION ssip_notify_root_change() RETURNS trigger AS $$
BEGIN
IF NEW.root IS DISTINCT FROM OLD.root THEN
-- Reuses the same UDP-from-PG-trigger plumbing as toupdate (U)
-- and toreboot (R). Format matches the listener: <cmd>|<id>.
PERFORM send_udp('K|' || NEW.id, '<hub_ip>', 1301);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ssip_devices_root_change_notify ON ssip_devices;
CREATE TRIGGER ssip_devices_root_change_notify
AFTER UPDATE OF root ON ssip_devices
FOR EACH ROW EXECUTE FUNCTION ssip_notify_root_change();Once the trigger is in place, the runtime flow on every root change:
operator: UPDATE ssip_devices SET root = 19 WHERE id = 867;
↓ (PG trigger)
udp send_udp('K|867', hub, 1301)
↓ (mod_ssip_hub listener thread)
notify pipe → main loop → on_notify_pipe (cmd 'K')
↓
- SELECT root FROM ssip_devices WHERE id = 867
- ensure_carrier_bot_user(core, 19, &key)
- core->send /ssip867/ssip/functions/set_hub_key key=<carrier-bot-19-key>
- emit /events/ssip/hub/device_root_changed device_id=867 new_root=19
↓ (federation)
device handle_set_hub_key (auth: caller is local `hub` user)
- sed -i peer_keys = ssip-hub:<new-key>
- sync; _exit(0)
↓ (systemd)
portal-default respawn → identity_proof exchange → hub resolves to dev-root-19
Verification: journalctl -u portal-ssip_hub shows Re-key: pushed new carrier-bot key to ssip867; journalctl -u portal-default on
ssip867 shows set_hub_key: peer_keys updated then a fresh
Identity exchange ok with peer 'ssip-hub' → local user 'dev-root-19'
on reconnect.
If the device is offline at notify time, the push fails (logged on
hub). The device migration handler /ssip/hub/functions/get_carrier_key
will resolve it automatically on next boot, since the device's
peer_keys line will no longer authenticate against any current
carrier-bot user on the hub.
The fleet is intentionally asymmetric:
- Devices → hub: minimal privilege. Every device in carrier
root_id = Nresolves on the hub to userdev-root-<N>with labelsssip-device, ssip<N>. Auto-provisioned bymod_ssip_hubon first register/restore. The carrier-bot api_key returns to the device on register-response line 3;mod_ssipwrites it topeer_keys = ssip-hub:<key>and_exit(0)s for respawn. - Hub → devices: maximal privilege. The hub's outbound key for
ssip<id>is the device's own 128-hex/etc/ssippwd, which equalsssip_devices.passwordin PG.mod_ssip_hubseeds these via the runtimeset_peer_keyAPI at module load AND on every register/restore. The device has a local userhubwhose api_key equals/etc/ssippwd(mod_ssipPhase 2b creates it at module load), with labelsroot, admin, hub-adminso the hub passes every gate that matters on the device.
Migration for already-deployed devices uses the
/ssip/hub/functions/get_carrier_key endpoint described in §5.4.
Operators don't need to re-register anything; a normal boot picks up
the missing key on the next cycle.
- Key length:
auth_user_t.api_keyis[AUTH_KEY_LEN + 1] = 65bytes./etc/ssippwdis 128 hex (SHA-512), so the localhubuser on the device only stores the first 64 chars. Phase 2c on the hub therefore truncates the device password to 64 chars before pushing topeer_keys. Mismatch makes identity exchange silently resolve to anonymous on one side. root = 0(dev / unassigned):ensure_carrier_bot_useraccepts it (createsdev-root-0with labelsssip-device, ssip0) so dev boxes still get a low-priv carrier-bot for identity exchange.
Two layers enforce "hub→device shell works, device→hub shell denied":
- Identity layer (covered by §5 and §6.2): a device→hub
/shell/functions/dialback_requestarrives asdev-root-<N>(labelsssip-device, ssip<N>), norootlabel → access_label gate denies → 403. - Listener layer (mod_shell). The dial-back TLS port (
:2223) has a Strategy A "DIRECT" mode — the initiator opens the TCP and the target runs/bin/sulocally. Strategy A bypasses Portal auth entirely (TLS-only path to a/bin/suprompt). On the hub, setshell_disable_direct_target = trueinmod_shell.confso the listener rejects everyDIRECTline. Operator dial-back from the hub uses session_id mode and is unaffected (matched against an internally-trackedpending_shellcreated by an authenticated/shell/functions/open_remoteon the hub side).
The two layers are independent — defeating one doesn't help an attacker.
For full detail and the per-phase commit ledger see the auto-memory
entry reference_federation_identity.md and
mod_ssip_hub/docs/create_update.md (operator-side workflow for the
broader SSIP rollout pipeline).
These exist independently of the federation-identity work and remain on the table.
| Severity | Module | Issue | Where |
|---|---|---|---|
| HIGH | mod_ssh |
Shell injection in system("ssh-keygen … -f %s ", g_host_key) |
mod_ssh.c:355-363 |
| HIGH | mod_ldap |
Bind-DN/password stored plaintext in conf; no LDAPS; LDAP filter & DN injection in snprintf(filter, …, g_user_filter, user) |
mod_ldap.c:128-144, 180-183, 250, 252 |
| MED | mod_firewall |
Rate limiter fails OPEN when tracker table fills (FW_MAX_TRACKERS = 1024) |
mod_firewall.c:124 |
| MED | mod_web |
Accepts ?api_key= in URL query string — visible in HTTP access logs |
mod_web.c:161-168 |
| MED | mod_audit |
Log file is plaintext + clearable by admin — no HMAC chain, no tamper evidence |
mod_audit.c:82-91, 281-294 |
| MED | (default) | tls_verify = false ships as default for federation TLS |
src/main.c template + mod_node.c:31 |
| LOW | core | mod_audit does not record federation identity exchange success/failure |
(gap) |
These are tracked here so the next person reading this doc can decide which to take. Fixing them does not require any architectural change; each is a localised module patch.
We defend against:
- A compromised SSIP device leaking its own key compromises only
that carrier's bot identity on the hub, which carries no admin
labels (Phase 3 gate keeps it away from
/users,/groups,/core/modules,/core/config/set). - A casual web caller without an API key cannot read user records (anonymous → unlabeled but path is in handler scope) and cannot mutate anything.
- An operator scoped to one carrier label does not see other carriers' devices on the SSIP updates dashboard (Law 15).
- A federation peer cannot trivially impersonate another peer's identity — the identity exchange ties keys to user records on the receiving side.
We do NOT defend against:
- An attacker with access to the shared
federation_keycan complete the PORTAL02 handshake and join the mesh as an anonymous peer. Combined with a leaked api_key for any peer they could impersonate that peer's identity. - An attacker who roots the hub host has access to the
users.conffile, the storage backend, and everymod_node.confcontainingpeer_keys. Hardening the host is out of scope for Portal itself. - An operator carrying
hub-adminis fully privileged on the hub — there is no second-factor or quorum step on user creation, key rotation, or module load. Treat thehub-adminlabel as the highest-trust assignment. - The audit log has no tamper-evidence (see §11). An admin who rooted the hub can clear it.
Mitigations not yet implemented: pieces in §11 + §13 below.
These are not blockers; they round out the model. Roughly ordered by payoff per line-of-code.
- Audit events for identity exchange — emit
/events/auth/identity_exchangedand/events/auth/identity_failedfrommod_node. ~30 lines. Closes §11 LOW item. - Show resolved user in
/node/resources/peers— add aResolved as: <user>column. ~10 lines. Diagnostic visibility. - Per-peer key rotation handler — one operator endpoint that
ensures user, generates new key, pushes via
set_peer_key, and (for SSIP) triggers per-device push via the existingset_hub_keyplumbing. - Flip
tls_verify = truedefault — once the operator has CA infrastructure. One-line change in the template. - The HIGH-severity items in §11 (mod_ssh injection, mod_ldap) whenever those modules are next touched.
docs/PHILOSOPHY.md— Laws 8, 9, 15 in their canonical form.docs/CORE_API.md— module API for auth + label primitives.docs/MODULE_GUIDE.md— idiomatic patterns for module authors.mod_ssip_hub/docs/create_update.md— operator workflow for the SSIP staged-rollout pipeline.- Auto-memory
reference_federation_identity.md— implementation ledger for Phase 1–5 with file:line citations.