Permissions
Capability-based authorization - permission strings, roles, resolution, and the guards server and client code use.
Permissions answer “what may this caller do?”. Africa OS uses capability-based authorization: a permission is a branded string in the shape application.resource.action, and the platform resolves the set of capabilities a user holds in an organization. Server functions check capabilities with guards; the UI shapes itself around the same set.
The permission model
A permission looks like this:
retail.products.manage
platform.applications.access
dashboard.apps.read
Three segments, each lowercase letters, digits, or hyphens, joined by dots. The shape is enforced by a regex and a Zod schema (platform/permissions/src/permission.ts):
const permissionPattern = /^[a-z0-9-]+\.[a-z0-9-]+\.[a-z0-9-]+$/;
export const permissionSchema = z
.string()
.regex(permissionPattern, "permission must look like application.resource.action");
The parsed value is a branded type, so capabilities are distinguishable from plain strings at the type level:
export type Permission = z.infer<typeof permissionSchema> & {
readonly __permission: unique symbol;
};
export const parsePermission = (value: string): Permission => { ... };
parsePermission is the single place permission strings enter the typed world. It fails with InvalidPermission on malformed input, and every guard fails closed: an unparseable string is never granted.
The resolution chain
A user’s capability set in an organization is resolved through a five-hop chain:
AuthorizationService.permissionsFor(userId, organizationId) joins across all five tables and returns a distinct Set<Permission> (platform/permissions/src/service.ts). Only active memberships count. A malformed seed row is skipped defensively rather than taking down request assembly.
Where roles come from
When an organization is created, OrganizationService.create inserts an owner role wired to every registered permission, so the founder can do anything. Organizations that manage their own access model create additional roles and assign them to memberships via member_roles.
The permission set is resolved once per request, in the request runtime, scoped to the active organization. It is provided to programs as CurrentPermissions.
The guards
platform/permissions/src/guard.ts provides the two functions application code uses:
can - the client-side mirror, used to shape UI:
const canManageProducts = yield* can("retail.products.manage");
// use it to show or hide a button
can returns an Effect.Effect<boolean> reading CurrentPermissions. It fails closed on malformed strings.
requirePermission - the server-side enforcement point:
yield* requirePermission("retail.products.manage");
// safe to proceed: the caller holds the capability
requirePermission fails with a typed Forbidden when the capability is absent (or unparseable). Server functions call it before acting, and the request runtime is the only source of the permission set.
Server vs client - the rule
The client check is never the sole guard. The pattern is:
- The client renders controls conditionally with
can(better UX, less noise). - The server function enforces with
requirePermission(the real gate).
A guarded server function must call requirePermission regardless of what the UI shows. Client-side hiding is a convenience; server-side guarding is the security boundary.
The application access gate
There is one special permission: platform.applications.access. The request runtime checks it before scoping a request to an application:
- The application must exist in the registry.
- It must be enabled for the active organization.
- The user must hold
platform.applications.access.
Only after all three pass does any domain permission inside the application matter. This is the platform-level gate, separate from domain capabilities.
Error handling
Authorization failures are typed, Effect-native values:
Forbidden({ permission })- thrown byrequirePermissionand the application gate.InvalidPermission({ value })- thrown byparsePermissionon malformed input.
Server functions catch Forbidden and fold it into a serializable result (typically a 403-style response), so callers never see an unhandled exception.
Next steps
- Request context - how the permission set gets resolved per request.
- Adding a feature guide - how to guard a new feature’s server functions.