Skip to content
tsbouncerpreview
recipe

RBAC — roles on resources

Attach a role to a resource instead of naming people, so onboarding is one write.

The situation

A product has an editor concept. Three people are editors, that changes monthly, and nobody wants to touch a list of user ids in eight different places.

The model

A role is a type whose holder relation is the assignment, and a userset edge is how a resource picks up “everyone holding that role”.

TypeScript
import { createAuthz, defineModel, defineType, permission, relation } from '@tsbouncer/tsbouncer';
import { memoryStore } from '@tsbouncer/in-memory';

const model = defineModel({
  types: {
    user: defineType({}),
    role: defineType({ relations: { holder: relation('user') } }),
    document: defineType({
      relations: {
        owner: relation('user'),
        editor: relation('user').or(relation('role', { through: 'holder' })),
      },
      permissions: {
        read: permission.or('owner', 'editor'),
        write: permission.allOf('owner', 'editor'),
      },
    }),
  },
});

const authz = createAuthz({ model, store: memoryStore() });

The data

Two kinds of tuple, and they answer different questions.

TypeScript
// Who holds the role. This changes when someone joins or leaves.
await authz.grant({ subject: 'user:alice', relation: 'holder', resource: 'role:acme:editor' });
await authz.grant({ subject: 'user:bob', relation: 'holder', resource: 'role:acme:editor' });

// Which resources carry it. This changes when a document is created.
await authz.grant({
  subject: 'role:acme:editor#holder',
  relation: 'editor',
  resource: 'document:1',
});

The result

TypeScript
await authz.can('user:alice', 'document.write', 'document:1'); // true  — owner and editor
await authz.can('user:bob', 'document.write', 'document:1'); // false — editor, not owner
await authz.can('user:bob', 'document.read', 'document:1'); // true

What this actually bought

Onboarding is one write and touches no document.

TypeScript
await authz.grant({ subject: 'user:carol', relation: 'holder', resource: 'role:acme:editor' });
// document:1 is now editable-read for carol. Nothing else changed.

That is the whole argument for modelling roles as data rather than as a column. A global admin stops being a magic string in a function signature and becomes a relation.

Do not let a role hand out everything

The write permission above needs owner and editor, so an editor role does not by itself grant write. Without that intersection, attaching editor to a document would hand write to everyone holding it — which is rarely what “editor” means.

TypeScript
write: permission.allOf('owner', 'editor'),

Roles can be scoped to a container

A role object is just an object, so an instance role can administer a tenant without the tenant naming anyone:

TypeScript
const workspaceModel = defineModel({
  types: {
    user: defineType({}),
    role: defineType({ relations: { holder: relation('user') } }),
    workspace: defineType({
      relations: {
        admin: relation('user').or(relation('role', { through: 'holder' })),
      },
      permissions: { administer: permission.or('admin') },
    }),
  },
});

const scoped = createAuthz({ model: workspaceModel, store: memoryStore() });

await scoped.grant({ subject: 'user:root', relation: 'holder', resource: 'role:instance:admin' });
await scoped.grant({
  subject: 'role:instance:admin#holder',
  relation: 'admin',
  resource: 'workspace:globex',
});
void scoped;

await scoped.can('user:root', 'workspace.administer', 'workspace:globex'); // true
await scoped.can('user:alice', 'workspace.administer', 'workspace:globex'); // false

What this is not

RBAC alone cannot express “the manager of the people who can see this document”. That is a graph, and it is the next recipe. Most real models need both.

Next

  • ReBAC — ownership, groups, and inheritance.
  • ABAC — gating on state the graph cannot hold.