Roles

Give users roles, let roles inherit others, and write rules that ask for a role.

@ultimabase/roles gives users roles, such as writer or team, and adds the rule hasRole. A role is a row of the role model, which defineRole declares with its members, the users:

npm install @ultimabase/roles
import { defineUser } from '@ultimabase/auth'
import { graphql } from '@ultimabase/graphql'
import { password } from '@ultimabase/password'
import { defineRole } from '@ultimabase/roles'
import { allow } from 'ultimabase'

// Each role has a unique name, its members, and the roles it inherits.
export const Role = defineRole({
	name: 'Role',
	members: () => User,
	use: [graphql()],
	// Clients read the roles; the server gives them.
}).with({ access: allow.readOnly })

The full example, with its tests: examples/roles-and-acl.

The user model may list its roles, the other side of members:

export const User = defineUser({
	name: 'User',
	use: [graphql(), password()],
	fields: (f) => ({ name: f.string(), roles: f.many(() => Role) }),
}).with({ access: allow.readOnly })

declare module 'ultimabase' {
	interface Register {
		readonly user: typeof User
	}
}

rolesPlugin() goes in the application's plugins. Here it comes with Authentication, which gives each request its user, and ACL:

plugins: [
	// Listens on PORT, or 3000.
	httpPlugin(),
	graphqlPlugin(),
	authPlugin(),
	passwordPlugin({ signUp: ['name'] }),
	// Reads each signed-in user's roles once per request.
	rolesPlugin(),
	aclPlugin(),
],

Give roles

A role is an ordinary row: Master creates it and connects its members, as any relation. Here the server gives Ada the writer role, and Ada and Grace the team role:

await Role.create(
	{ data: { name: 'writer', members: members(ada) } },
	{ as: master },
)
await Role.create(
	{ data: { name: 'team', members: members(ada, grace) } },
	{ as: master },
)

The role model above is read-only for clients: only the server gives roles. Its own access decides otherwise, as any model's.

Ask for a role

hasRole('writer') is a rule: in the Note model, only a writer creates a note.

import { ACL, acl } from '@ultimabase/acl'
import { graphql } from '@ultimabase/graphql'
import { hasRole } from '@ultimabase/roles'
import { allow, defineModel } from 'ultimabase'

export const Note = defineModel({
	name: 'Note',
	use: [
		graphql(),
		// A new note is its author's alone: no one else reads or writes it.
		acl({
			default: ({ caller }) =>
				caller.kind === 'user' ? new ACL().allowUser(caller.user) : new ACL(),
		}),
	],
	fields: (f) => ({ title: f.string() }),
}).with({
	// The ACL picks the rows; the rules pick who writes a new note.
	access: {
		read: allow.public,
		create: hasRole('writer'),
		update: allow.public,
		delete: allow.public,
	},
})
expect(await request(create, {}, grace.token)).toMatchObject(
	refused('ACCESS_DENIED'),
)
expect(await request(create, {}, ada.token)).toMatchObject({
	data: { createNote: { title: 'Plans' } },
})

hasRole('admin', 'editor') lets through a user who has either role. It is false for a visitor, and for Master, who needs no role. In a function or a hook, hasRole(caller, 'writer') answers the same question as a boolean.

Inherit roles

A role's inherits lists the roles it includes: a member of editor, which inherits writer, is a writer too. inheritedBy is the other side.

// An editor is a writer too, and Bob is an editor.
const writer = await Role.findUniqueOrThrow(
	{ where: { name: 'writer' } },
	{ as: master },
)
await Role.create(
	{
		data: {
			name: 'editor',
			members: members(bob),
			inherits: { connect: [{ id: writer.id }] },
		},
	},
	{ as: master },
)
expect(await request(create, {}, bob.token)).toMatchObject({
	data: { createNote: { title: 'Plans' } },
})
expect(await request('mutation { myRoles }', {}, bob.token)).toEqual({
	data: { myRoles: ['editor', 'writer'] },
})

rolesOf(caller) lists the caller's roles, inherited ones included, each { id, name }; the myRoles function above returns their names:

// The caller's roles, inherited ones included, read once per request.
export const myRoles = defineFunction({
	name: 'myRoles',
	output: z.array(z.string()),
	run: ({ caller }) => rolesOf(caller).map(({ name }) => name),
}).with({ access: isAuthenticated })

The plugin reads a user's roles once per request, before any rule runs, so a rule asking for a role runs no query. A role given during a request counts from the next one.

Reference

defineRole(definition)

KeyTypeEffect
namestringThe model's name, as 'Role'.
members() => UserThe user model, which the members are rows of.
fields(f) => ({ ... })The role's own fields, as in defineModel.
useplugin usesThe model's plugins, as [graphql()].
indexesindex listAs in defineModel.
dbNamestringThe stored table's or collection's name.

It adds the fields:

FieldTypeEffect
namestring, uniqueThe name rules ask for.
membersmany of the user modelThe users who have the role.
inheritsmany of the role modelThe roles a member has too.
inheritedBymany of the role modelThe roles that inherit this one.

.with takes the same keys as a model's, as access. An application has one role model.

rolesPlugin()

It takes no option and must be in plugins when a model is declared with defineRole. For each signed-in user, it reads the roles once per request, following inherits five levels per query, as deep as they go; a cycle ends where it loops.

Functions

FunctionReturnsEffect
hasRole(...names)a ruleThe caller has one of the roles, inherited ones included; false for a visitor or Master.
hasRole(caller, ...names)booleanThe same, for a caller in a function or a hook.
rolesOf(caller){ id, name }[]The caller's roles, inherited ones included; [] for a visitor or Master.

Errors

At start:

  • An application without a role model: rolesPlugin() needs a role model: declare it with defineRole.
  • Two role models: models lists two models defineRole returned: an application has one role model.
  • members that is not a function: defineRole(Role): members is () => the user model.
  • A field of its own named name, members, inherits or inheritedBy: defineRole(Role): inherits is a field defineRole adds.

When a rule runs:

  • hasRole for a user, without rolesPlugin(): hasRole reads the roles rolesPlugin() gives a caller: add rolesPlugin() to plugins.
  • hasRole() without a name: hasRole takes a role name.

On this page