Oxagen Docs

Access Control & Audit

How Oxagen gates capability calls with workspace-scoped ACL grants and records every invocation in an append-only audit trail.

Every read, write, generation, and external call in Oxagen runs through a single dispatch path: the capability registry. The registry checks an Access Control List (ACL) before it runs the handler, then writes a row to the audit trail when the call completes. The same checks apply whether the caller is a user in the web app, an API client with a bearer token, an MCP client, or an agent acting on a workspace.

Capabilities are the unit of permission

A capability is a typed function exposed by the platform (e.g. ontology.search, docs.create_from_spec, external.salesforce.upsert). Each capability declares a kind that describes what it does:

KindWhat it doesDefault policy
readPure read against workspace state. Idempotent.Any tenant member, no grant required
writeMutates graph nodes, edges, types, memory, or agent state.Requires grant (OWNER role allowed)
generateInvokes an LLM or other generative model.Requires grant
external_ioCalls a third-party service (Gmail, Drive, Stripe, etc.).Requires grant
dispatchSchedules background or asynchronous work.Requires grant

This taxonomy is the floor. ACL grants override the defaults in either direction — you can deny a read to a specific user, or allow a generation capability to an agent role.

ACL grants

A grant lives on a single workspace and binds three things together:

(workspace, principal, capability pattern)  →  allow | deny
FieldWhat it means
workspace_idThe workspace the grant applies to. Grants do not cross workspaces.
principal_kinduser, tenant_role, agent_definition, or any_member. Determines how the principal is matched.
principal_idThe user UUID, when principal_kind is user. Null otherwise.
principal_roleThe role name (OWNER, MEMBER) or agent slug, depending on kind. Null when not applicable.
capability_globA glob pattern that matches one or more capabilities. Examples: ontology.read, docs.*, external.salesforce.*.
effectallow or deny.
expires_atOptional UTC timestamp. After it passes, the grant stops matching.
granted_by_idThe user who created the grant.

Principal kinds

principal_kindMatches
userA single authenticated user, identified by UUID.
tenant_roleEvery user in the workspace's tenant who holds the named role — OWNER or MEMBER.
agent_definitionEvery agent run dispatched under the named agent slug.
any_memberAny authenticated user with membership in the workspace's tenant.

Capability globs

Capability patterns use shell glob syntax against the dotted capability name. A grant for docs.* matches docs.create_from_spec, docs.append_to_google_doc, and any future docs.<x> capability. A grant for external.salesforce.upsert matches only that one capability.

ontology.search                    # one capability
docs.*                             # every docs capability
external.salesforce.*              # every Salesforce capability
*                                  # every capability — use sparingly

Decision flow

Each capability call resolves to one decision using a fixed precedence:

The precedence is, top to bottom:

  1. Explicit deny grants — highest priority. One matching deny blocks the call regardless of other allow grants.
  2. Explicit allow grants — any matching allow permits the call.
  3. Role defaults — the tenant role OWNER is granted write capabilities by default. No other role-based defaults apply.
  4. Kind defaultsread capabilities are open to any tenant member; write, generate, external_io, and dispatch capabilities require an explicit grant.

When the call is denied, the response is HTTP 403 on the API surface and an access_denied error in MCP. The reason string from the evaluator is returned to the caller and written to the audit row.

Worked example

A workspace has two ACL grants:

GrantEffectCapability globPrincipal
Marketing team can run generationallowgenerate.*tenant_role = MEMBER
External Salesforce writes blockeddenyexternal.salesforce.*any_member

A MEMBER calling generate.image is allowed by grant #1. A MEMBER calling external.salesforce.upsert is denied by grant #2, because explicit deny outranks every other rule. An OWNER calling external.salesforce.upsert is also denied — the deny grant fires for any member, including owners.

Caching and invalidation

The ACL evaluator caches decisions per workspace. When a grant is created, revoked, or updated, the cache for that workspace is invalidated and the next call re-evaluates from the grant table. Grants in other workspaces are unaffected.

Audit trail

The audit trail lives in the dedicated audit schema and is append-only from the application's perspective. There are three tables.

audit.capability_invocations

One row per capability call, written by the registry dispatch function. Records timing, status, and credits for every read, write, generation, and external call, regardless of which surface initiated it.

ColumnWhat it holds
workspace_idWorkspace the capability was called in.
user_idAuthenticated end user, or null for system-initiated calls.
agent_run_idAgent run UUID when the call came from an agent, otherwise null.
callerSurface that initiated the call: api, mcp, or app.
capability_nameDotted capability name (e.g. ontology.search).
capability_kindOne of read, write, generate, external_io, dispatch.
statussuccess, error, denied, or cancelled.
error_codeMachine-readable error code on failures.
input_hashSHA-256 of the canonicalized input JSON.
output_hashSHA-256 of the canonicalized output JSON. Null on error or denial.
creditsCredits charged for the invocation.
latency_msEnd-to-end latency in milliseconds.
idempotency_keyCaller-supplied idempotency key when present.
is_replaytrue when the row represents a cached idempotency hit, not a fresh handler run.
started_atUTC timestamp when dispatch began.
ended_atUTC timestamp when the handler returned. Null when the call was cancelled before completion.

audit.audit_events

One row per operator-initiated mutation — sharing changes, credit adjustments, subscription updates, user deactivations. Each row carries the acting user, the action, the target resource, a sanitized payload, the source IP, and the user agent.

ColumnWhat it holds
actor_user_idUser who performed the action.
actor_emailDenormalized email at time of action — survives a deleted user row.
actionDotted action key (e.g. content_studio.revoke_public, credits.adjust).
target_typeResource type (tenant, user, subscription, etc.).
target_idUUID of the affected row, or null for catalog-wide actions.
payloadSanitized JSON context. Never contains secrets, tokens, or full PII.
ip_addressSource IP from the left-most public hop in the request chain.
user_agentUser-Agent header from the originating request.

audit.ontology_events

One row per ontology write — node, edge, or type mutations performed by agents or users. Records the event kind (e.g. auto_merge, confirmed_merge, delete_node, rename_type), the actor, the affected resource, and event-specific details (such as kept/dropped IDs for merges or repointed edge counts).

What is not stored

The audit trail records the shape of every call without retaining its content. Inputs and outputs are SHA-256 hashed — the registry never persists raw prompt text, raw document bodies, or third-party API request bodies. The payload field on audit_events is passed through a deny-list scrubber before insert.

This pairs with the broader storage minimization described in Security & Privacy.

Audit writes never fail the business call

Audit log writes run in a nested SAVEPOINT. A failure on the audit insert — for example, a transient database error — is logged but does not roll back the capability handler's transaction. The capability call succeeds or fails on its own merits; the audit row is best-effort and recoverable independently.

Querying the trail

The audit tables are workspace-scoped where applicable and follow the same PostgreSQL Row-Level Security model as the rest of the platform. A workspace can only see its own capability_invocations and ontology_events rows; audit_events is global and gated to staff.

Indexes on the trail tables support the common access patterns:

  • Capability calls by workspace and time range
  • Capability calls by workspace and capability name
  • Operator actions by actor
  • Operator actions by target resource
  • Ontology events by workspace and resource

The trail is the system of record for SOC 2 access-control and change- management evidence and is the input to internal compliance review.

Grant lifecycle in practice

When you provision a new agent or a new integration in a workspace, set the minimum grants the agent needs and nothing more. Use tenant_role or agent_definition grants in preference to per-user grants — they survive team changes and are easier to audit. Use deny grants to carve out specific capabilities from a broader allow — for example, allow docs.* but deny docs.share_public.

Set expires_at on grants that exist for a finite reason — a contractor engagement, a one-off backfill, a temporary debug session. Expired grants stop matching automatically and remain in the table for audit history.

Responsible disclosure

Found an access-control or audit issue? Email security@oxagen.ai. Responses go out within 24 hours. Please do not open public GitHub issues for security vulnerabilities.

On this page