Vol. 001 Jeffs Brain·Documentation

Source concepts/acl

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.

ConceptValues
Subjectuser, api_key, service
Actionread, write, delete, admin, export
Resourceworkspace, 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:

RoleRank
admin3
writer2
reader1

Action -> minimum role:

ActionMin role
readreader
writewriter
deleteadmin
adminadmin
exportreader

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.

SDKPackageImport
TypeScript@jeffs-brain/memory-openfgaimport { createOpenFgaProvider } from '@jeffs-brain/memory-openfga'
Gogithub.com/jeffs-brain/memory/go/aclopenfgaimport "github.com/jeffs-brain/memory/go/aclopenfga"
Pythonjeffs_brain_memory.acl_openfgafrom jeffs_brain_memory.acl_openfga import create_openfga_provider, OpenFgaOptions

The default action -> relation map matches the schema:

ActionRelation
readreader
writewriter
deletecan_delete
adminadmin
exportcan_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) check read on the resolved resource.
  • write and append check write.
  • delete checks delete.
  • rename checks write on src, then write on dst.
  • list(dir) only checks when dir is non-empty; root listings are unguarded so callers can discover what they have access to without preflighting every brain.
  • batch wraps the inner batch handle so every nested op is guarded with the same rules.
  • localPath always returns nothing; the wrapper refuses to leak a filesystem path because the provider check is async.
  • subscribe/close pass 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 with isForbidden(err).
  • Go: *acl.ForbiddenError whose Is method returns true for brain.ErrForbidden, so existing errors.Is(err, brain.ErrForbidden) handlers keep matching.
  • Python: ForbiddenError(AccessControlError, ErrForbidden) so existing except 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.