Security and authorization
Thalix Vault uses deny-by-default server-side authorization. Client behavior improves safety and usability, but the backend makes every final decision. For definitions of admin override, delegation, ownership, anti-enumeration, and authorization, see Glossary.
Trust model
- Network traffic uses TLS.
- File storage is private and blocks anonymous listing and reads.
- The development CLI holds only a revocable, scoped device token and safe metadata in the private configuration file defined below.
- Users and local agents never receive permanent database, storage, signing, or service credentials.
- Audit details exclude passwords, tokens, credentials, and file bytes.
- IDs exposed to users are opaque and non-sequential.
CLI credential file boundary
The CLI credential storage boundary is a private configuration file, not an operating-system credential-store or Keychain dependency. The file uses $XDG_CONFIG_HOME/thalix-vault/credentials.json on macOS and Linux, with ~/.config as the XDG fallback. Windows requires an explicit absolute %APPDATA% and uses %APPDATA%\\thalix-vault\\credentials.json; a missing or relative value fails closed without deriving another path.
The file accepts one exact, bounded JSON object containing the opaque device tv_ token, validated application origin, expiry, fixed account:read scope, safe token prefix, and kind: "device" discriminator. Unknown fields and legacy provider-session shapes are rejected. The CLI never stores provider access or refresh credentials.
Writes create an exclusive temporary file in the same directory and atomically replace credentials.json, preserving the prior credential when failure occurs before replacement. On POSIX systems, the CLI requires a private 0700 directory and 0600 file. On Windows, it requires a non-inherited, owner-verified private ACL whose allowed principals are the current user, SYSTEM, and built-in Administrators. It verifies the protected directory and installed file after replacement.
The store refuses symbolic links, Windows reparse points, and detected directory, file, or path-identity swaps. If it cannot establish or verify the required permissions, ownership, identity, or atomic replacement, it fails closed with no weaker storage fallback. ADR-0005 records this decision and its consequences.
CLI commands send that opaque token only as an internal bearer. The server hashes it before the database boundary and resolves only an active, unexpired, unrevoked account:read token whose profile and organization membership remain active. Deactivation therefore denies existing tokens immediately without any provider refresh credential or refresh-token protocol.
Organization roles
| Role | Organization administration | File content |
|---|---|---|
global_admin | Unrestricted bootstrap and administrative authority | Unrestricted list/read/download across the organization; every access is audited with adminOverride, reason optional |
admin | Users, roles, Root Folder deactivation/organization-wide visibility, metadata definitions, audit retrieval, group discovery/oversight | Unrestricted list/read/download across the organization; every access is audited with adminOverride, reason optional |
user | No organization administration | Access through ownership or valid grants, direct or inherited from a Root Folder ancestor |
The first verified account matching the configured bootstrap identity receives global_admin through a server-side auditable process. Later registration is designed to be invitation-only, but the invitation-creation surface itself is not yet built — see Invitations for the honest current gap.
File permission levels
| Level | Inspect / download | Add content version | Change metadata / archive | Manage grants |
|---|---|---|---|---|
read | Yes | No | No | No |
write | Yes | Yes | No | No |
manage | Yes | Yes | Yes | Yes, within delegation limits |
Ownership implies manage for the owned file. The same rule applies to a Root Folder: its owner resolves manage on it implicitly, with no stored grant row for that ownership; a subfolder's owner is always its root's owner, so manage flows down from the root regardless of who created the subfolder -- see Domain model.
Creating a subfolder (tv folder create <name> --parent <folder-id>) additionally requires the actor to hold live manage on the immediate parent at request time -- read/write are insufficient, and a grant made or revoked between two calls takes effect on the very next one, with nothing decided or cached client-side. A parent that does not exist, sits in a different organization/root tree, or that the caller cannot manage all produce the same anti-enumerating FOLDER_PARENT_NOT_FOUND.
Grant model
A grant targets a user or group, applies to one file or one Root Folder, and carries read, write, or manage. A Root Folder grant applies to that folder, every descendant subfolder beneath it, and every file contained anywhere in that subtree (issue #55) -- Root Folder ownership likewise implies manage throughout the folder's entire subtree, not only the folder itself.
Effective permission is the highest valid permission derived from ownership, a direct grant, a group grant, or a grant on any ancestor Root Folder at any distance up the tree, resolved live on every call. MVP has no deny rule, time condition, nested group, or arbitrary policy language.
Delegation rules
- Only an owner or a user with
managemay create, change, or revoke a grant. - A delegating user cannot grant more authority than they currently possess, and a folder grant may never delegate beyond the subtree the grantor currently manages -- both bounds are re-checked live on every grant attempt, never cached from when authority was first established.
- Moving a file to another Root Folder immediately recalculates Root Folder-scoped access (direct, group, and inherited from the new parent's ancestor chain) and warns when effective access changes. Moving a subfolder immediately recalculates every inherited grant for its whole subtree against its new ancestor chain -- no permission snapshot is ever copied at move time.
- Revoking a grant, removing a group member, or deactivating a subject changes effective access immediately for every folder and file it reaches, including everyone who held access only through that grantor's own now-revoked delegated authority.
- File and Root Folder ownership transfer is not supported; group ownership transfer is supported (see Group model below).
Group model
Group roles are owner, manager, and member, scoped to one group; groups are organization-local and flat (no nesting). The creator of a group becomes its sole owner. A group can never be left with zero active owners: owners manage roles, invitations, and ownership transfer; managers may invite and remove members but cannot demote or remove the last remaining owner and cannot promote anyone to owner. tv group transfer atomically hands sole ownership to another active member in one step, so a group is never observably ownerless mid-operation. If a group's sole active owner is deactivated while other active members remain, deactivation itself is refused (GROUP_OWNER_DEACTIVATION_BLOCKED) until ownership transfers; if a group is nonetheless left with zero active owners, it enters an explicit recovery state an administrator resolves by promoting an existing member (tv admin group transfer) without demoting anyone else. Administrators can always discover and audit any group (tv admin group list/members) but never gain implicit membership.
Group invitations follow pending -> accepted | declined | revoked | expired. Only the invited member may accept or decline; a resend refreshes the same pending row rather than creating a duplicate, and a group holds at most one pending invitation per email. Inviting an email that is unknown, belongs to a different organization, or is deactivated returns the identical success response a genuine invitation would produce, but persists no invitation or membership row — existence of an account is exactly as protected here as file existence is elsewhere in this document (see Anti-enumeration below).
Search and metadata confidentiality
Authorization is part of the backend query predicate. The service never returns inaccessible rows for the CLI to filter. This protects file names, owners, metadata, version facts, and existence from unauthorized discovery. Tag filters (issue #59) run against the same already-authorization-filtered candidate set as every other search predicate, never widening it -- a hidden file's tags can never affect a visible result count, cursor, or response timing.
Security verification priorities
Authorization requires exhaustive table-driven tests across roles, ownership, direct and group grants, Root Folder scope, delegation, account status, archive state, and negative search cases. Infrastructure tests must prove private-by-default object behavior and absence of anonymous access. Group ownership, role, and invitation logic requires the same exhaustive treatment: the last-active-owner invariant across role change/removal/deactivation, owner/manager/member escalation boundaries, the invitation lifecycle, and anti-enumeration for invite targets. Recursive folder-tree resolution (issue #55) requires exhaustive coverage of direct, group, inherited, and administrator-override paths at every subtree depth; folder moves, revocation, group-membership removal, and deactivation must each be proven to change effective access immediately; delegation must be proven bounded to the grantor's own live manage subtree with no escalation path; and administrator list/read/download must be proven unrestricted within one organization while remaining structurally impossible across a second one. Folder-subtree search (issue #57) requires the same live-recalculation and anti-enumeration coverage applied to tv file find --within <folder-id> specifically: deep-tree inclusion (the start folder plus every descendant, never a sibling or an ancestor), AND-composition with every other existing filter, access filtering applied before pagination counts so a hidden subtree file never consumes a page slot, a folder moved into or out of the subtree between two page fetches taking effect immediately with no snapshot, and complete, audited administrator override coverage. Tag-based search (issue #59) requires the identical live-recalculation and authorization-before-pagination coverage applied to tv file find --tag <name>[=<value>]: exact-value and existence-only matching, multiple --tag filters composing with AND (never OR) and producing no duplicate result rows, case normalization identical to tv file tag set/unset, composition with --within/--meta/name-date-status filters and keyset pagination in one query plan, a tag mutation changing results on the very next call with no snapshot, and query-plan/index evidence proving the (file_id, normalized_name) index is used rather than a JSON or sequential scan.
Anti-enumeration
A caller without sufficient access to a file, a folder, an access grant, or an audit target sees the identical NOT_FOUND (or, for search/browse, an absent row) that a genuinely nonexistent target would produce. This applies uniformly across tv file info/get, tv folder list <folder-id>, tv file find --within <folder-id> (issue #57), tv file access list/grant/revoke, and tv folder access list/grant/revoke: existence itself is authorization-gated information, not just content. --within's start folder resolves with the identical rule tv folder list <folder-id> already uses (owned, or covered by a direct or inherited grant) -- a folder the caller cannot see, one that does not exist, and one that belongs to a different organization all fail identically with NOT_FOUND, never a distinguishing error; an administrator without standing on the start folder is the same deliberate, fully audited exception described above. For an ordinary user, tv folder list [<folder-id>] (issue #56) returns only the immediate children of a folder they can already access -- a hidden folder ID and a nonexistent one are indistinguishable; an administrator is the one deliberate exception -- the identical command browses any folder in the organization for them unconditionally, an explicit and audited unrestricted-visibility rule, never a silent leak. tv folder create's FOLDER_NAME_CONFLICT reports only that the caller's own name choice collides with a Root Folder they already own, never who else, if anyone, already uses that name. tv group invite extends this to organization membership itself: an unknown, cross-organization, or deactivated invitee produces the same success response a real invitation would, so a group owner can never use invitation as an account-discovery oracle. A --tag/--meta filter that matches nothing simply returns an empty page (issue #59) -- never a distinct error or a different response shape a caller could use to probe whether a given tag name or value exists anywhere in the organization.
Purge is structurally forbidden
There is no permanent-delete path anywhere in this codebase for a file, a content version, a metadata revision, or an audit event. files/file_versions carry BEFORE DELETE/BEFORE UPDATE triggers that unconditionally reject the operation with a custom 'TVPRG' SQLSTATE for every database role, including service_role — a trigger has no RLS-bypass exception the way ordinary privilege checks do. See Trust boundaries for the full mechanism.
Append-only audit is a database guarantee
Every audit table carries the same append-only trigger pattern, and admin_audit_list is the sole SECURITY DEFINER retrieval surface over the unified audit_events_view — administrators can filter and page audit history, never edit or remove it. See Trust boundaries.
Redaction allowlist
Audit details never pass through unfiltered. public.safe_audit_details is a single allowlisted-key, scalar-only, length-capped builder every audit-event write calls unconditionally — no caller can bypass it. Only a fixed set of keys survive (reason, operation, domain, versionId, revisionNo, filename, folderId, folder, withinFolderId, nameFilter, ownerFilter, mimeType, status, resultCount, hasMore, adminOverride, requiredLevel, grantedLevel, fileId); string values are truncated at 500 bytes and the whole object at 2048 bytes. As defense in depth, a key matching (token|secret|password|credential|bearer|signed|bytes|authoriz) (case-insensitive) is dropped even if it were ever accidentally allowlisted, so a future well-meaning addition like sessionToken still cannot leak anything.