Authorisation
The pluggable Provider contract, the workspace -> brain -> collection -> document hierarchy, the in-process RBAC adapter, and the OpenFGA adapter shipped by every SDK.
Jeffs Brain ships a single authorisation surface across all three SDKs. You wrap a Store once with a Provider and a subject, and every read, write, and delete runs an authorisation check before it touches the backend. The contract is identical in TypeScript, Go, and Python; the in-box RBAC adapter is identical too; and a sibling OpenFGA HTTP adapter is shipped for production use.
Resource hierarchy
Authorisation is scoped to a four-level hierarchy:
workspace -> brain -> collection -> document
A grant at any level cascades down through parent tuples. A deny tuple at any level overrides grants reachable through inheritance.
Subjects, actions, results
Every check is a (subject, action, resource) tuple.
| Concept | Values |
|---|---|
| Subject | user, api_key, service |
| Action | read, write, delete, admin, export |
| Resource | workspace, brain, collection, document |
check returns { allowed: boolean, reason?: string }. The Store wrapper turns allowed: false into a typed forbidden error at the boundary.
In-process RBAC
The default in-box adapter persists tuples in memory (or any tuple store you plug in) and resolves checks with three roles per resource:
| Role | Rank |
|---|---|
admin | 3 |
writer | 2 |
reader | 1 |
Action -> minimum role:
| Action | Min role |
|---|---|
| read | reader |
| write | writer |
| delete | admin |
| admin | admin |
| export | reader |
A deny:<role> tuple at any ancestor blocks any action whose required role is <= deny role. So deny:admin on a brain blocks delete on a child document; deny:reader blocks every action below it as well.
TypeScript
import {
createRbacProvider,
grantTuple,
parentTuple,
withAccessControl,
} from '@jeffs-brain/memory/acl'
const acl = createRbacProvider()
await acl.write?.({
writes: [
parentTuple({ type: 'brain', id: 'notes' }, { type: 'workspace', id: 'acme' }),
grantTuple({ kind: 'user', id: 'alice' }, 'writer', { type: 'workspace', id: 'acme' }),
],
})
const guarded = withAccessControl(
store,
acl,
{ kind: 'user', id: 'alice' },
{ resource: { type: 'brain', id: 'notes' } },
)
Go
import "github.com/jeffs-brain/memory/go/acl"
provider := acl.NewRbacProvider(acl.RbacOptions{})
_ = provider.Write(ctx, acl.WriteTuplesRequest{
Writes: []acl.Tuple{
acl.ParentTuple(
acl.Resource{Type: acl.ResourceBrain, ID: "notes"},
acl.Resource{Type: acl.ResourceWorkspace, ID: "acme"},
),
acl.GrantTuple(
acl.Subject{Kind: acl.SubjectUser, ID: "alice"},
acl.RoleWriter,
acl.Resource{Type: acl.ResourceWorkspace, ID: "acme"},
),
},
})
guarded := acl.Wrap(store, provider, acl.Subject{Kind: acl.SubjectUser, ID: "alice"}, acl.WrapOptions{
Resource: acl.Resource{Type: acl.ResourceBrain, ID: "notes"},
})
Python
from jeffs_brain_memory import (
wrap_store, Subject, Resource, create_rbac_provider,
grant_tuple, parent_tuple, WriteTuplesRequest,
)
acl = create_rbac_provider()
await acl.write(WriteTuplesRequest(writes=[
parent_tuple(Resource(type="brain", id="notes"), Resource(type="workspace", id="acme")),
grant_tuple(Subject(kind="user", id="alice"), "writer", Resource(type="workspace", id="acme")),
]))
guarded = wrap_store(
store, acl, Subject(kind="user", id="alice"),
resource=Resource(type="brain", id="notes"),
)
OpenFGA adapter
For production tuple-store backed authorisation every SDK ships a sibling adapter that talks to an OpenFGA server (or any API-compatible proxy) over plain HTTP. The shared model lives at spec/openfga/schema.fga and is loaded into your OpenFGA store with fga model write.
The adapter implements the same Provider contract as the in-process RBAC, so withAccessControl / acl.Wrap / wrap_store accept it interchangeably.
| SDK | Package | Import |
|---|---|---|
| TypeScript | @jeffs-brain/memory-openfga | import { createOpenFgaProvider } from '@jeffs-brain/memory-openfga' |
| Go | github.com/jeffs-brain/memory/go/aclopenfga | import "github.com/jeffs-brain/memory/go/aclopenfga" |
| Python | jeffs_brain_memory.acl_openfga | from jeffs_brain_memory.acl_openfga import create_openfga_provider, OpenFgaOptions |
The default action -> relation map matches the schema:
| Action | Relation |
|---|---|
| read | reader |
| write | writer |
| delete | can_delete |
| admin | admin |
| export | can_export |
Wrap semantics
The Store wrapper applied by withAccessControl / acl.Wrap / wrap_store enforces the same rules in every SDK:
- Read-side methods (
read,exists,stat,list) checkreadon the resolved resource. writeandappendcheckwrite.deletechecksdelete.renamecheckswriteon src, thenwriteon dst.list(dir)only checks whendiris non-empty; root listings are unguarded so callers can discover what they have access to without preflighting every brain.batchwraps the inner batch handle so every nested op is guarded with the same rules.localPathalways returns nothing; the wrapper refuses to leak a filesystem path because the provider check is async.subscribe/closepass through unchanged.
Resource resolution is per-call. Pass resource for the common one-Store-per-brain shape, or resolveResource: (path) => Resource when documents inside a single Store map to different resources.
Forbidden errors
Denied calls raise a typed forbidden error in every SDK that interoperates with the existing error taxonomy:
- TypeScript:
ForbiddenError extends AccessControlError. Check withisForbidden(err). - Go:
*acl.ForbiddenErrorwhoseIsmethod returns true forbrain.ErrForbidden, so existingerrors.Is(err, brain.ErrForbidden)handlers keep matching. - Python:
ForbiddenError(AccessControlError, ErrForbidden)so existingexcept ErrForbidden:blocks still catch it.
Lifecycle
Every adapter implements close (optional in TS, required in Go and Py). The in-process RBAC close is a no-op. The TS OpenFGA adapter’s close is a no-op (the fetch transport owns no pool). The Go OpenFGA adapter’s close is a no-op (sharing http.DefaultTransport). The Python OpenFGA adapter’s close aclose()s the internal httpx.AsyncClient only when the adapter constructed it, leaving caller-supplied clients alone, and is idempotent.
Call close (or defer provider.Close() in Go) when you’re done with a provider. Adapters that own no state today still expose the hook so swap-in providers that own real state get released cleanly.
Server-side scoping
The wire contract scopes a request to one brain via the URL (/brains/<id>/...). The server resolves the owning tenant from the bearer principal and returns 403 if the principal cannot reach the requested brain. API-key scopes (documents:read, documents:write) layer on top. See spec/PROTOCOL.md for the full wire surface.