Authorization

Authorization decides what each signed-in principal may see and do. Reach for it when "signed in or not" is not enough — finance admins run sensitive workflows, team members read invoices but can't refund them, and new teammates land in the right group.

Authentication decides who a principal is; authorization decides what they may do. Sixb builds it from four small layers:

LayerRole
GroupsNamed buckets that principals belong to
RolesBundles of grants attached to groups
GrantsThe capabilities granted by a role
Membership policiesWho can administer membership for which groups

At request time these resolve into one set of grants per principal, and the Sixb SDK enforces them. You describe access next to the ontology, datasets, actions, workflows, syncs, and pipelines it protects, and the runtime applies it the same way everywhere.

Define a group

A group is a named bucket. Principals belong to groups; roles and membership policies are written against groups, never against individual users.

TS
// security/groups/team-members.ts
import { defineGroup } from "@sixb/core"

export const teamMembers = defineGroup("team-members", {
  label: "Team members",
})
TS
// security/groups/finance-admins.ts
import { defineGroup } from "@sixb/core"

export const financeAdmins = defineGroup("finance-admins", {
  label: "Finance admins",
})

Define a role

A role bundles grants and attaches them to one or more groups. Every member of a grantedTo group receives the role's grants.

TS
// security/roles/billing-access.ts
import { applications, can, defineRole } from "@sixb/core"
import { sendReminder } from "../../actions/sendReminder"
import { Customer } from "../../ontology/customer"
import { Invoice } from "../../ontology/invoice"
import { teamMembers } from "../groups/team-members"

export const teamMemberBillingAccess = defineRole("team-member.billing-access", {
  grantedTo: [teamMembers],
  grants: [
    can.access(applications.app),
    can.view([Customer, Invoice]),
    can.apply(sendReminder),
  ],
})

Every member of team-members can now view Customer and Invoice objects and apply the sendReminder action — nothing else.

PartMeaning
defineRole("team-member.billing-access")Names the role
grantedTo: [teamMembers]Groups whose members receive the role
grants: [...]The capabilities the role gives
can.access(applications.app)Allow access to the custom app
can.view([Customer, Invoice])Allow viewing those object types
can.apply(sendReminder)Allow applying the sendReminder action

A role needs at least one grantedTo group and at least one grant. A principal's effective grants are the union of every role whose grantedTo group it belongs to.

Grants

A grant pairs a capability with the definitions it covers. Eight capability builders — can.access, can.view, can.edit, can.append, can.apply, can.run, can.manage, and can.observe — resolve to twelve grant kinds, one per protected target family.

Grant kindBuilderAllowsTargets
access:applicationcan.access(...)Open a grant-controlled browser applicationapplications.atlas, applications.app
view:objectcan.view(...)Read objects: get, list, query, telemetry, related eventsObject types
view:datasetcan.view(...)Read datasets and their versionsDatasets
edit:objectcan.edit(...)Write objects: properties, links, delete, restoreObject types
append:telemetrycan.append(...)Append telemetry pointsObject types
apply:actioncan.apply(...)Request actionsActions
run:workflowcan.run(...)Start workflowsWorkflows
run:synccan.run(...)Run syncsSyncs
run:pipelinecan.run(...)Run pipelinesPipelines
run:agentcan.run(...)Run agents and read their threadsAgents
manage:connectorcan.manage(...)Authorize, select, disconnect, and revoke OAuth connector accountsConnectors
observe:logscan.observe("logs")Read captured run logsLogging

can.access accepts the built-in applications.atlas and applications.app definitions. can.view resolves to view:object or view:dataset from the definition you pass; can.run picks between run:workflow, run:sync, run:pipeline, and run:agent the same way. Each is type-checked, so mixing target families in one call does not compile. can.manage accepts connector definitions. can.observe takes the "logs" target literal and grants observe:logs, which gates reading captured logs.

Only can.view(Type) reaches subtypes. can.edit and can.append cover exactly the types you name, so adding a type under one you granted never makes it writable on its own.

Writing takes two grants; see why.

Selecting definitions

Each builder takes one definition, a list, or a breadth selector.

WantWrite
One applicationcan.access(applications.atlas)
One definitioncan.view(Invoice)
Several definitionscan.view([Customer, Invoice])
Every object typecan.view(every.object())
Write every object typecan.edit(every.object())
Ingest telemetry everywherecan.append(every.object())
Every datasetcan.view(every.dataset())
Every actioncan.apply(every.action())
Every workflowcan.run(every.workflow())
Every synccan.run(every.sync())
Every pipelinecan.run(every.pipeline())
Every agentcan.run(every.agent())
Every connectorcan.manage(every.connector())
Every applicationcan.access(every.application())
Everything but a fewcan.view(every.object().except([Customer]))

The breadth selectors live on one every namespace exported from @sixb/core. Each picks its target family's whole registered universe and is branded by target, so can.view(every.action()) does not compile — and passing the wrong family from untyped code throws at definition time rather than silently granting the wrong universe.

Broad grants

Use breadth selectors for roles that should reach most of the system. Add .except([...]) to keep a selection broad while carving out a few definitions.

TS
// security/roles/billing-access.ts
import { can, defineRole, every } from "@sixb/core"
import { financeAdmins } from "../groups/finance-admins"

export const financeAdminFullAccess = defineRole("finance-admin.full-access", {
  grantedTo: [financeAdmins],
  grants: [
    can.view(every.object()),
    can.edit(every.object()),
    can.view(every.dataset()),
    can.apply(every.action()),
    can.run(every.workflow()),
  ],
})
TS
// Grant every object type except Customer
can.view(every.object().except([Customer]))

Application access

Application grants control whether a signed-in principal may open Atlas or the custom app.

Known limitation in 0.1.x. Application access is the only capability that is not deny-by-default. While no role mentions an application, every authenticated principal may open it. The allowlist switches on only once some role grants that application — after which principals without the grant get an access-denied page before any application data loads. view, apply, run, manage, and observe all deny unless granted, so this is the single asymmetry in the model. If you want Atlas closed, grant it explicitly to the groups that should reach it; the grant is what turns enforcement on.

TS
import { applications, can, defineRole } from "@sixb/core"

export const securityAdminAtlasAccess = defineRole("security-admin.atlas-access", {
  grantedTo: [securityAdmins],
  grants: [can.access(applications.atlas)],
})

export const customerAppAccess = defineRole("customer.app-access", {
  grantedTo: [customers],
  grants: [can.access(applications.app)],
})

Application access complements resource grants rather than replacing them. For example, a user may be allowed into the custom app but still see only the object types granted to their groups. Atlas and the custom app enforce application grants at the session, HTTP, and WebSocket boundaries.

Membership policies

A membership policy says which groups can administer membership, which groups they may administer, and which operations they may perform. Inviting is one membership operation; managing existing members uses the same boundary.

TS
// security/policies/member-administration.ts
import { defineMembershipPolicy } from "@sixb/core"
import { financeAdmins } from "../groups/finance-admins"
import { teamMembers } from "../groups/team-members"

export const memberAdministration = defineMembershipPolicy("member-administration", {
  grantedTo: [financeAdmins],
  scope: [teamMembers],
  can: ["invite", "assignGroups", "suspend"],
})

Finance admins can now invite people into team-members, edit existing team-members users' groups, and suspend or reactivate those users. A group-less invitation is also allowed when a caller has the invite operation; the resulting user can authenticate but receives no group-derived grants.

OptionMeaning
grantedToGroups whose members hold the policy
scopeGroups those members may administer. Existing-member operations require every current target group to be in scope; group-less targets are allowed when the operation exists.
canMembership operations: invite, assignGroups, suspend

Membership operations are intentionally scoped:

OperationAllows
inviteCreate, list, and revoke invitations whose requested groups are all in scope. Empty group invitations are allowed when the caller has the operation.
assignGroupsReplace an existing user's groups when every current group and every requested group is in scope. A user cannot remove any of their own current groups.
suspendSuspend active users and reactivate suspended users when every current group is in scope. A user cannot suspend themself. Suspending revokes active sessions immediately; reactivation does not restore old sessions.

The server and Atlas use the same scope for visibility. Existing users are listed only when the caller can assign groups or suspend/reactivate over the user's current groups, so out-of-scope emails, statuses, and group membership are not exposed.

Asking what a caller may administer

sixb.auth.getMembershipCapabilities({ callerGroups }) answers the same question the member-admin routes ask, from groups alone and with no Request involved — so project code, a custom UI, and a test can all ask it the way the routes do.

TS
const capabilities = sixb.auth.getMembershipCapabilities({ callerGroups: [financeAdmins] })

capabilities.holds.suspend                     // any policy grants `suspend` at all
capabilities.assignableGroupIds                // groups this caller may assign
capabilities.covers("suspend", [teamMembers])  // the scope reaches a member holding these groups

Both accept group definitions or ids. Pass definitions when your code knows the groups, so a rename is a compile error; pass ids when they came from a session or a member's stored memberships, which is all a route or a UI has.

TS
const session = await sixb.auth.getSession(request)
if (session.authenticated) {
  sixb.auth.getMembershipCapabilities({ callerGroups: session.groupIds })
}

The three answer different questions, and the difference matters:

QuestionUse it for
holdsDoes any policy grant this operation, whatever the groups?Enabling a control at all — a "Suspend" menu that should not exist for this caller
coversDoes the scope reach a member currently holding these groups?Offering the control for one specific member row
the runtime methodMay this operation run, right now, on this target?The decision itself

Coverage is not authorization. covers reports the group boundary the policies draw; the operation applies rules that boundary cannot see. suspendMember refuses the current user even when the scope covers their groups. assignGroups also checks the groups being assigned against assignableGroupIds, which covers is not asked about. A member's status can rule the operation out on its own. Use the capability query to decide what to show, and the runtime method to decide what happens.

How principals join groups

Roles and membership policies act on group membership, so principals need a way into a group.

  • Bootstrap — the auth strategy's bootstrapGroups are applied to the first allowed user to sign in. This seeds the initial admins.
  • Invitations and member management — after that, members covered by a membership policy invite teammates, assign groups, and suspend or reactivate users inside the policy's scope.
TS
import { magicLink } from "@sixb/auth-magic-link"
import { financeAdmins } from "./security/groups/finance-admins"

auth: magicLink({
  allowedDomains: ["acme.com"],
  bootstrapUsers: ["admin@acme.com"],
  bootstrapGroups: [financeAdmins],
})

The first user to sign in as admin@acme.com lands in finance-admins, which (via the role and membership policy above) can then invite and manage the rest of the team. See Authentication for how strategies and bootstrapping work.

How grants are enforced

Grants are enforced through the Sixb SDK. SixbHost owns providers, definitions, and lifecycle; it does not expose a privileged version of protected domain operations.

The server and workers provide the appropriate SDK before application code runs:

TS
await sixb.objects(Invoice).list()      // only if view:object covers Invoice
await sixb.objects(Invoice).upsert(...)  // only if view:object AND edit:object cover Invoice
await sixb.actions.request(input)       // only if apply:action covers it
await sixb.workflows.requestById(input) // only if run:workflow covers it
await sixb.events.read()                // events whose subject is visible

The SDK is default-deny for signed-in principals: any request without a covering grant throws, and listing APIs return only the definitions the principal can reach.

Protected SDK surface

The SDK exposes only operations whose grants are enforceable end to end. Catalog and read methods are filtered to what the principal may reach. Auth administration, infrastructure handles, and process lifecycle stay on SixbHost.

MethodGated by
objects(Type), objects.list, objects.get, objects.getPrimaryPropertyIdview:object
objects.upsert, objects.upsertBatch, byId().delete(), byId().restore()view:object + edit:object
objects.upsertLink, objects.upsertLinkBatch, objects.removeLink, byId().link(), byId().unlink()edit:object on the source, view:object on the target
objects.appendTelemetry, appendTelemetryBatch, byId().telemetry().append()append:telemetry
actions.request, actions.requestAndWaitapply:action
workflows.requestByIdrun:workflow
syncs.requestrun:sync
pipelines.requestrun:pipeline
datasets.list, datasets.getByIdview:dataset
actions.list, actions.getByIdapply:action
workflows.list, workflows.getByIdrun:workflow
syncs.list, syncs.getByIdrun:sync
pipelines.list, pipelines.getByIdrun:pipeline
agents.request, agents.list, agents.getById, agents.threads.*, agents.runs.*run:agent
events.readsubject visibility (see below)

Why writing needs the read grant too

An upsert answers with the merged row: the Materializer reconciles your write against whatever else asserts that object — a projection, an action — so the response can carry properties you never sent. edit:object therefore takes effect only alongside view:object.

append:telemetry is the exception, and deliberately so. An append answers { success: true } and nothing else, so it needs no read grant. That is what makes a write-only principal expressible:

TS
// A device pushes readings and can read nothing at all.
export const sensorIngest = defineRole("sensor.ingest", {
  grantedTo: [devices],
  grants: [can.append(Sensor)],
})

Links follow the type they are declared on. Writing Invoice.l.customer needs edit:object on Invoice and only view:object on Customer — a clerk attaching a customer to an invoice does not need write access to customers. The read rule already works this way: seeing a link requires view:object on both endpoints.

Writes are not free of intent. A grant says who may write; it does not record why. Prefer an action for anything a person would call a decision: it validates, it is recorded as a run, and the resulting events name the action. Direct writes are for integrations and ingestion, where there is no decision to record.

Event visibility

There is no standalone "view events" capability. A principal sees a domain event only when it can view, apply, or run the event's subject:

Event topicVisible when
objects, telemetrycan view the object type
linkscan view both the source and target object types
actionscan apply the action (and view its object subject, if any)
rulescan view the object type the rule fired on
workflowscan run the workflow
syncscan run the sync
pipelinescan run the pipeline
datasetscan view the dataset
schedulesalways visible to an authorized reader (no subject grant yet)

Event filtering is fail-closed: an unmodeled topic is hidden. See Events for the event model.

With the server

The Sixb server does this for you. It resolves the session once per request and applies that principal's grants, so read and run grants are enforced without extra wiring. You define groups, roles, and membership policies; the server applies them. See the Server overview.

Tests bind explicit test executions with createTestSixb:

TS
const context = resolveAuthorizationContext({ principal, groupIds, roles })
const sixb = createTestSixb(host, { authorization: context })

Convention

Put security definitions under security/, split by kind, and export them.

TXT
your-project/
  ontology/
    invoice.ts
    customer.ts
  actions/
    sendReminder.ts
  security/
    groups/
      team-members.ts
      finance-admins.ts
    roles/
      billing-access.ts
    policies/
      member-administration.ts
  sixb.config.ts

createSixb() discovers exported definitions from security/groups/, security/roles/, and security/policies/ automatically. See Project structure. You can also register them explicitly:

TS
import { createSixb } from "@sixb/core"
import { financeAdmins } from "./security/groups/finance-admins"
import { teamMembers } from "./security/groups/team-members"
import { memberAdministration } from "./security/policies/member-administration"
import { financeAdminFullAccess, teamMemberBillingAccess } from "./security/roles/billing-access"

export const sixb = createSixb({
  groups: [teamMembers, financeAdmins],
  roles: [teamMemberBillingAccess, financeAdminFullAccess],
  membershipPolicies: [memberAdministration],
})

How to model authorization

Start from the people, not the permissions.

  1. List the kinds of user your app has, and turn each into a group.
  2. For each group, write one role describing what it can view, apply, and run.
  3. Start narrow with explicit grants; widen to a breadth selector or .except([...]) only when a group really needs broad reach.
  4. Add a membership policy so the right group can grow and manage the others.
  5. Set bootstrapGroups so the first sign-in can administer everything else.

Name groups and roles after the people and their access: team-members, finance-admins, team-member.billing-access, finance-admin.full-access.

Grants reference ontology, dataset, action, workflow, sync, and pipeline definitions by id and are validated against the registered runtime at startup, so a typo or unregistered definition fails fast.

Search docs

Search the documentation