28 KiB
Organization & Access — Model Reference
Module: organization
Schema: organization
Source: data-model.md §Organization & Access
This file is a projection of the master data model reference. The technical content is identical to the corresponding section of data-model.md. When the master reference is updated, this file must be updated in the same change.
Organization & Access
Organization & Access answers: "What containers exist, and who can access them with what permissions?"
organizations
An organization is the top-level container for all resources, members, and billing relationships. Every person operates within the context of at least one organization.
There are no solo users in this model. Every person has a personal organization created automatically. This design choice means:
- The data model is uniform whether someone is solo or on a team
- Solo users can invite collaborators without migration
- All resources, billing, and access control work the same way regardless of organization size
- Organizations can be personal (one owner), team (multiple members), or enterprise (formal business)
Platform administration convention: The cooperative itself is represented as an organization in the model (e.g., slug = platform). Platform administrators are members of this organization with the platform_admin role. This reuses the existing org → members → roles mechanism for platform-level governance without requiring new schema entities.
| Field | Type | Purpose |
|---|---|---|
org_id |
UUID | Primary key |
name |
VARCHAR(255) | Display name of the organization. |
slug |
VARCHAR(100) | URL-safe identifier. Globally unique. Used in URLs and API references. |
org_type |
VARCHAR(20) | Classification: personal (auto-created for individuals), team (collaborative), enterprise (formal business). |
owner_person_id |
UUID | FK → persons. For personal organizations, the person who owns it. Required when org_type = personal. |
legal_name |
VARCHAR(255) | Registered legal name of the business entity. |
entity_type |
VARCHAR(50) | Legal structure: llc, corporation, nonprofit, cooperative, sole_prop. |
tax_id |
VARCHAR(100) | Business tax identifier (EIN for US companies). |
website |
VARCHAR(255) | Organization's website. |
settings |
JSONB | Organization-level configuration and preferences. |
status |
VARCHAR(20) | State: active, suspended, deleted. |
suspended_at |
TIMESTAMPTZ | When most recently suspended. |
suspended_by |
UUID | FK → persons. Who suspended. |
deleted_at |
TIMESTAMPTZ | When deleted. |
deleted_by |
UUID | FK → persons. Who deleted. |
created_at |
TIMESTAMPTZ | When this organization was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- Personal organizations must have an
owner_person_id. Enforced by:CHECK (org_type != 'personal' OR owner_person_id IS NOT NULL). slugis globally unique to enable clean URLs.
Status lifecycle: Governed by the Soft-Delete and Terminal State Policy.
active→suspended(reversible): operational pause,suspended_atandsuspended_byrecorded.active→deleted(terminal): soft-delete,deleted_atanddeleted_byrecorded.
Relationships:
organizations(0..1) → (1)persons: Personal organizations have an owner person.FK -> identity.personsorganizations(1) → (0..*)org_members: Organizations have members.organizations(1) → (0..*)workspaces: Organizations contain workspaces.organizations(1) → (0..*)billing_accounts: Organizations have billing accounts.FK -> billing.billing_accountsorganizations(1) → (0..*)resource_pools: Organizations have resource pools.FK -> entitlements.resource_poolsorganizations(1) → (0..*)roles: Organizations may define custom roles.organizations(1) → (0..*)service_accounts: Organizations have service accounts.organizations(1) → (0..*)invitations: Organizations may have pending invitations.
org_members
An org_member represents the membership of a person in an organization with a specific role. This is the primary mechanism for organization-level access control.
Membership is distinct from ownership and distinct from scoped role assignments. Membership represents belonging — a person is part of this organization. Role assignments represent access — an actor can do specific things in a specific scope. A person might have a workspace-scoped role assignment in an organization they are not a member of (e.g., a contractor with access to one workspace).
Memberships are created upon invitation acceptance or direct administrative action — they are born active, never pending. Invitation state is tracked on the invitations entity.
| Field | Type | Purpose |
|---|---|---|
org_member_id |
UUID | Primary key |
org_id |
UUID | FK → organizations. The organization. |
person_id |
UUID | FK → persons. The person who is a member. |
role_id |
UUID | FK → roles. The role this person has in this organization. |
invitation_id |
UUID | FK → invitations. The invitation that created this membership. NULL if created without an invitation (e.g., auto-created for personal org owner, or platform-level administrative action). |
status |
VARCHAR(20) | Membership state: active, suspended, removed. |
suspended_at |
TIMESTAMPTZ | When most recently suspended. |
suspended_by |
UUID | FK → persons. Who suspended. |
removed_at |
TIMESTAMPTZ | When removed. |
removed_by |
UUID | FK → persons. Who removed. |
created_at |
TIMESTAMPTZ | When this membership was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- A person can only be a member of an organization once. The combination of
org_idandperson_idis unique.
Status lifecycle: Governed by the Soft-Delete and Terminal State Policy.
active→suspended(reversible):suspended_atandsuspended_byrecorded.active→removed(terminal):removed_atandremoved_byrecorded.
Relationships:
org_members(0..*) → (1)organizations: Memberships belong to an organization.org_members(0..*) → (1)persons: Memberships belong to a person.FK -> identity.personsorg_members(0..*) → (1)roles: Memberships have a role.org_members(0..*) → (0..1)invitations: Memberships may trace their provenance to an invitation.
invitations
An invitation is a request for someone to join an organizational scope (organization or workspace) with a specific role. Invitations exist independently of both persons and org_members — the invitee may not yet have an account.
Invitee identity is progressive: an invitation begins addressed to an email and acquires richer identity references as its lifecycle progresses. This is a deliberate departure from the exclusive arc pattern used elsewhere in the model — the invitee identity evolves over the invitation's lifecycle rather than being fixed at creation.
| Field | Type | Purpose |
|---|---|---|
invitation_id |
UUID | Primary key |
invitee_email |
VARCHAR(255) | Email address of the invitee. NULL if inviting an existing person directly. |
invitee_person_id |
UUID | FK → persons. NULL if email-only invitation. May be populated when an existing person is discovered for the email. |
org_id |
UUID | FK → organizations. If this is an organization-scoped invitation. NULL if workspace-scoped. |
workspace_id |
UUID | FK → workspaces. If this is a workspace-scoped invitation. NULL if org-scoped. |
role_id |
UUID | FK → roles. The role to be granted upon acceptance. |
invited_by_person_id |
UUID | FK → persons. Who created the invitation. |
token_hash |
VARCHAR(255) | Hashed invitation token. Plaintext sent via email/notification. |
token_prefix |
VARCHAR(10) | First characters for identification (e.g., mc_inv_d8f...). |
message |
TEXT | Optional personal message from the inviter. |
sent_at |
TIMESTAMPTZ | When the invitation was first sent. |
last_sent_at |
TIMESTAMPTZ | When most recently sent (initial or resend). |
send_count |
INTEGER DEFAULT 1 | Total times sent (initial + resends). |
expires_at |
TIMESTAMPTZ | When the invitation expires. Reset on resend. |
accepted_at |
TIMESTAMPTZ | When accepted. |
resolved_person_id |
UUID | FK → persons. The person who accepted. May be newly created (new signup) or pre-existing (existing user). Set upon acceptance. |
resulting_member_id |
UUID | FK → org_members. If acceptance created a membership. |
resulting_assignment_id |
UUID | FK → role_assignments. If acceptance created a role assignment. |
declined_at |
TIMESTAMPTZ | When declined. |
revoked_at |
TIMESTAMPTZ | When revoked. |
revoked_by_person_id |
UUID | FK → persons. Who revoked. |
revocation_reason |
TEXT | Why revoked. |
status |
VARCHAR(20) | pending, sent, accepted, declined, expired, revoked. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Constraints (invitee — inclusive OR):
CHECK (invitee_email IS NOT NULL OR invitee_person_id IS NOT NULL)
At least one of invitee_email or invitee_person_id must be set. Both may coexist — this is progressive identity, not an exclusive arc. An invitation starts with the information available at creation time and acquires richer references as the lifecycle progresses.
Constraints (scope — exclusive arc):
CHECK (
(org_id IS NOT NULL AND workspace_id IS NULL)
OR (org_id IS NULL AND workspace_id IS NOT NULL)
)
Exactly one of org_id or workspace_id must be set. An invitation targets one organizational scope.
Uniqueness: Partial unique indexes enforce at most one active (non-terminal) invitation per (invitee, scope) combination.
Token format convention: mc_inv_<random>. Token is regenerated on resend (old token becomes invalid, new hash stored).
Status lifecycle:
pending→sent: Notification delivered.sent→sent: Resend (resetsexpires_at, incrementssend_count).sent→accepted,declined,expired,revoked: Terminal states, all irrevocable.pending→expired,revoked: Terminal without delivery.
Relationships:
invitations(0..*) → (0..1)persons(viainvitee_person_id): Invitations may be addressed to a known person.FK -> identity.personsinvitations(0..*) → (0..1)persons(viaresolved_person_id): Accepted invitations record who accepted.FK -> identity.personsinvitations(0..*) → (1)persons(viainvited_by_person_id): Invitations are created by a person.FK -> identity.persons- Exclusive arc — scope (
org_id/workspace_id):invitations(0..*) → (0..1)organizations: Org-scoped invitations target an organization.invitations(0..*) → (0..1)workspaces: Workspace-scoped invitations target a workspace.
invitations(0..*) → (1)roles: Invitations specify a role to grant.invitations(0..*) → (0..1)org_members(viaresulting_member_id): Accepted org-scoped invitations create a membership.invitations(0..*) → (0..1)role_assignments(viaresulting_assignment_id): Accepted workspace-scoped invitations create an assignment.
workspaces
A workspace is a container for resources within an organization. Workspaces provide isolation, organization, and optionally different access controls within a single organization.
| Field | Type | Purpose |
|---|---|---|
workspace_id |
UUID | Primary key |
org_id |
UUID | FK → organizations. The organization this workspace belongs to. |
name |
VARCHAR(255) | Display name of the workspace. |
slug |
VARCHAR(100) | URL-safe identifier. Unique within the organization. |
description |
TEXT | Explanation of what this workspace is for. |
environment |
VARCHAR(20) | Classification: development, staging, production. Informational. |
settings |
JSONB | Workspace-level configuration. |
created_by_person_id |
UUID | FK → persons. Who created this workspace. |
status |
VARCHAR(20) | State: active, archived, deleted. |
archived_at |
TIMESTAMPTZ | When archived. |
archived_by |
UUID | FK → persons. Who archived. |
deleted_at |
TIMESTAMPTZ | When deleted. |
deleted_by |
UUID | FK → persons. Who deleted. |
created_at |
TIMESTAMPTZ | When this workspace was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
slugis unique within an organization. The combination oforg_idandslugis unique.
Status lifecycle: Governed by the Soft-Delete and Terminal State Policy.
active→archived(reversible):archived_atandarchived_byrecorded. Suspends access and prevents new resource creation but preserves all data.active→deleted(terminal):deleted_atanddeleted_byrecorded. Signals permanent decommissioning.
Relationships:
workspaces(0..*) → (1)organizations: Workspaces belong to an organization.workspaces(0..*) → (0..1)persons: Workspaces track who created them.FK -> identity.personsworkspaces(1) → (0..*)role_assignments: Workspaces may have scoped role assignments.workspaces(1) → (1..*)pool_assignments: Workspaces are assigned to one or more resource pools.FK -> entitlements.pool_assignmentsworkspaces(1) → (0..*)usage_events: Workspaces record resource consumption.FK -> entitlements.usage_eventsworkspaces(1) → (0..*)invitations: Workspaces may have pending workspace-scoped invitations.
roles
A role defines a named set of permissions. Roles determine what actions an actor (person or service account) can perform within an organization, workspace, pool, or other scope.
Roles are either system-defined (built-in, applicable to all organizations) or organization-defined (custom roles for a specific organization). System roles provide consistent semantics across the platform. Custom roles allow organizations to tailor access control to their needs.
Permissions are flat and explicit. Each role states exactly what it grants as an array of permission strings. There is no implicit derivation, no inheritance. Given an actor's roles, you can enumerate their exact permissions without traversal.
| Field | Type | Purpose |
|---|---|---|
role_id |
UUID | Primary key |
org_id |
UUID | FK → organizations. If set, this is a custom role for this organization. If NULL, this is a system role. |
role_name |
VARCHAR(100) | Machine-readable identifier. Unique within scope (system or per-org). |
display_name |
VARCHAR(255) | Human-readable name for UI. |
description |
TEXT | Explanation of what this role is for. |
is_system |
BOOLEAN | Whether this is a built-in system role. System roles cannot be deleted or modified. |
permissions |
TEXT[] | Array of permission strings. Format: resource:action (e.g., billing:manage, workspace:delete). |
created_at |
TIMESTAMPTZ | When this role was created. |
updated_at |
TIMESTAMPTZ | Last modification timestamp. |
Constraints:
- Custom roles may only contain permissions from the defined vocabulary. Application-level validation prevents invalid or stale permission strings.
System roles:
| Role | Purpose | Key Differentiators |
|---|---|---|
owner |
Full authority over the organization. | Includes org:delete, org:transfer. Only role that can destroy or transfer the organization. |
admin |
Full access except irreversible org-level actions. | Everything except org:delete, org:transfer. |
member |
Can work within workspaces and manage resources. | No billing, pool, or org-structure access. |
billing |
Financial access only. | Can manage billing accounts, subscriptions, purchases, invoices. Cannot access workspaces or resources. |
viewer |
Read-only access to all visible entities. | Can see everything but modify nothing. |
platform_admin |
Platform-level administrative access. | Includes entitlement_rules:manage. Assigned within the platform organization only. |
Relationships:
roles(0..*) → (0..1)organizations: Custom roles belong to an organization. System roles have no organization.roles(1) → (0..*)org_members: Roles are assigned to organization members.roles(1) → (0..*)role_assignments: Roles are assigned at scoped level.roles(1) → (0..*)invitations: Roles are referenced by pending invitations.
role_assignments
A role_assignment grants a role to an actor (person or service account) for a specific scope. This enables finer-grained access control than organization-level membership.
Organization membership (via org_members) grants a baseline role to persons across the organization. Role assignments allow refinements and exceptions:
- A person might be a viewer at org level but an admin for a specific workspace.
- A contractor might have access to only one workspace, not the whole organization.
- A service account might have access to exactly one workspace for CI/CD deployment.
- Temporary access can be granted with an expiration date.
Service accounts have no org membership — their permissions come entirely from role assignments. This enforces minimum privilege by design.
| Field | Type | Purpose |
|---|---|---|
assignment_id |
UUID | Primary key |
person_id |
UUID | FK → persons. If the assignee is a person. NULL if service account. |
service_account_id |
UUID | FK → service_accounts. If the assignee is a service account. NULL if person. |
role_id |
UUID | FK → roles. The role being granted. |
scope_org_id |
UUID | FK → organizations. If this assignment is organization-scoped. NULL otherwise. |
scope_workspace_id |
UUID | FK → workspaces. If this assignment is workspace-scoped. NULL otherwise. |
scope_pool_id |
UUID | FK → resource_pools. If this assignment is pool-scoped. NULL otherwise. |
granted_by_person_id |
UUID | FK → persons. Who granted this role. For audit trail. |
granted_at |
TIMESTAMPTZ | When this role was granted. |
expires_at |
TIMESTAMPTZ | When this role expires. NULL means no expiration. |
revoked_at |
TIMESTAMPTZ | When this role was revoked. |
revoked_by_person_id |
UUID | FK → persons. Who revoked. |
status |
VARCHAR(20) | State: active, revoked, expired. |
created_at |
TIMESTAMPTZ | When this assignment was created. |
Constraints (exclusive arc — actor):
CHECK (
(person_id IS NOT NULL AND service_account_id IS NULL)
OR (person_id IS NULL AND service_account_id IS NOT NULL)
)
Exactly one of person_id or service_account_id must be set.
Constraints (exclusive arc — scope):
CHECK (
(scope_org_id IS NOT NULL AND scope_workspace_id IS NULL AND scope_pool_id IS NULL)
OR (scope_org_id IS NULL AND scope_workspace_id IS NOT NULL AND scope_pool_id IS NULL)
OR (scope_org_id IS NULL AND scope_workspace_id IS NULL AND scope_pool_id IS NOT NULL)
)
Exactly one of scope_org_id, scope_workspace_id, or scope_pool_id must be set.
Uniqueness: The combination of actor (person_id or service_account_id), role_id, and scope (scope_org_id or scope_workspace_id or scope_pool_id) is unique. An actor cannot be assigned the same role to the same scope twice.
Relationships:
- Exclusive arc — actor (
person_id/service_account_id):role_assignments(0..*) → (0..1)persons: Assignments may belong to a person.FK -> identity.personsrole_assignments(0..*) → (0..1)service_accounts: Assignments may belong to a service account.
role_assignments(0..*) → (1)roles: Assignments grant a role.- Exclusive arc — scope (
scope_org_id/scope_workspace_id/scope_pool_id):role_assignments(0..*) → (0..1)organizations: Assignments may be scoped to an organization.role_assignments(0..*) → (0..1)workspaces: Assignments may be scoped to a workspace.role_assignments(0..*) → (0..1)resource_pools: Assignments may be scoped to a pool.FK -> entitlements.resource_pools
service_accounts
A service_account is a non-human actor with its own identity and permissions. It represents an automated system or integration that interacts with the platform's API independently of any person.
Service accounts belong to organizations, not persons. They are created and managed by organization administrators. They have their own role assignments, independent of any person's permissions.
Service accounts are distinct from personal access tokens: a PAT acts as a person (inheriting their permissions); a service account acts as itself (with independently granted permissions). When a person leaves an organization, their PATs are revoked, but service accounts they created continue operating.
Service accounts are locally-managed by default, with optional Keycloak linkage. The oidc_subject and oidc_issuer fields allow linking to a Keycloak client's service account identity without requiring it. Creating a Keycloak client for every service account is unnecessarily heavy; local API keys are the primary authentication mechanism.
| Field | Type | Purpose |
|---|---|---|
service_account_id |
UUID | Primary key |
org_id |
UUID | FK → organizations. The organization this service account belongs to. |
name |
VARCHAR(255) | Display name (e.g., "CI/CD Pipeline", "Nextcloud Connector", "Backup Service"). |
description |
TEXT | What this service account does. |
oidc_subject |
VARCHAR(255) | If linked to a Keycloak service account: the OIDC subject identifier. NULL for locally-managed accounts. |
oidc_issuer |
VARCHAR(255) | If linked to Keycloak: the issuer. NULL for locally-managed accounts. |
created_by_person_id |
UUID | FK → persons. Who created this service account. |
status |
VARCHAR(20) | active, suspended, deleted. |
suspended_at |
TIMESTAMPTZ | When most recently suspended. |
suspended_by |
UUID | FK → persons. Who suspended. |
deleted_at |
TIMESTAMPTZ | When deleted. |
deleted_by |
UUID | FK → persons. Who deleted. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Status lifecycle: Governed by the Soft-Delete and Terminal State Policy.
active→suspended(reversible): operational pause.active→deleted(terminal): soft-delete.
Relationships:
service_accounts(0..*) → (1)organizations: Service accounts belong to an organization.service_accounts(0..*) → (0..1)persons: Service accounts track who created them.FK -> identity.personsservice_accounts(1) → (0..*)service_account_keys: Service accounts have API keys.service_accounts(1) → (0..*)role_assignments: Service accounts receive scoped role assignments.
service_account_keys
A service_account_key is a credential for a service account. Service accounts may have multiple keys to support rotation: a new key is created before the old one is retired, ensuring uninterrupted access during the transition.
| Field | Type | Purpose |
|---|---|---|
key_id |
UUID | Primary key |
service_account_id |
UUID | FK → service_accounts. |
name |
VARCHAR(255) | Human-readable name (e.g., "Production key", "Rotation 2026-02"). |
key_hash |
VARCHAR(255) | Hashed key value. Plaintext shown once at creation. |
key_prefix |
VARCHAR(10) | First few characters for identification (e.g., "mc_sak_b7d..."). |
expires_at |
TIMESTAMPTZ | When this key expires. NULL for no expiration. |
last_used_at |
TIMESTAMPTZ | When this key was last used. |
last_used_ip |
INET | IP address of last use. |
revoked_at |
TIMESTAMPTZ | When revoked. |
revoked_by_person_id |
UUID | FK → persons. Who revoked it. |
status |
VARCHAR(20) | active, expired, revoked. |
created_at |
TIMESTAMPTZ | |
updated_at |
TIMESTAMPTZ |
Token format convention: mc_sak_<random> (Member Console, Service Account Key). Distinct from PATs for immediate identification.
Status lifecycle:
active→expired(passive): key reachesexpires_at.active→revoked(terminal): explicit revocation with actor attribution.
Relationships:
service_account_keys(0..*) → (1)service_accounts: Keys belong to a service account.service_account_keys(0..*) → (0..1)persons(viarevoked_by_person_id): Revocation actor.FK -> identity.persons
Permission System
Permission String Format
Permissions follow the format resource:action. Resources correspond to entity types and management surfaces. Actions are a small, consistent set.
Actions:
| Action | Meaning |
|---|---|
view |
Read/list the resource |
create |
Create new instances |
edit |
Modify existing instances |
delete |
Remove instances |
manage |
Full CRUD plus resource-specific administrative actions |
Permission Vocabulary
Organization:
org:view, org:edit, org:delete, org:transfer, org.members:view, org.members:manage, org.service_accounts:view, org.service_accounts:manage
Workspaces:
workspace:view, workspace:create, workspace:edit, workspace:delete, workspace.resources:view, workspace.resources:manage
Resource Pools:
pool:view, pool:create, pool:edit, pool:delete, pool.assignments:view, pool.assignments:manage, pool.ondemand:view, pool.ondemand:manage
Billing:
billing:view, billing:manage, billing.subscriptions:view, billing.subscriptions:manage, billing.purchases:view, billing.purchases:create, billing.invoices:view
Grants & Entitlement Rules:
grants:view, grants:manage, entitlement_rules:view, entitlement_rules:manage
Administration:
roles:view, roles:manage, audit:view, tokens:manage
System Role Permission Sets
owner:
org:view, org:edit, org:delete, org:transfer, org.members:view, org.members:manage, org.service_accounts:view, org.service_accounts:manage, workspace:view, workspace:create, workspace:edit, workspace:delete, workspace.resources:view, workspace.resources:manage, pool:view, pool:create, pool:edit, pool:delete, pool.assignments:view, pool.assignments:manage, pool.ondemand:view, pool.ondemand:manage, billing:view, billing:manage, billing.subscriptions:view, billing.subscriptions:manage, billing.purchases:view, billing.purchases:create, billing.invoices:view, grants:view, grants:manage, entitlement_rules:view, roles:view, roles:manage, audit:view
admin: Same as owner minus org:delete, org:transfer.
member:
org:view, org.members:view, workspace:view, workspace.resources:view, workspace.resources:manage, pool:view, pool.assignments:view, billing.invoices:view
billing:
org:view, billing:view, billing:manage, billing.subscriptions:view, billing.subscriptions:manage, billing.purchases:view, billing.purchases:create, billing.invoices:view, pool:view, pool.ondemand:view
viewer:
org:view, org.members:view, workspace:view, workspace.resources:view, pool:view, pool.assignments:view, pool.ondemand:view, billing:view, billing.subscriptions:view, billing.purchases:view, billing.invoices:view, audit:view
platform_admin: All admin permissions plus entitlement_rules:view, entitlement_rules:manage. Assigned within the platform organization only.
Permission Resolution Algorithm
Permissions are additive (union) and deny-by-default. An actor can do nothing unless a permission explicitly allows it. There are no deny rules.
For persons:
effective_permissions(person, context) =
permissions_from_org_membership(person, context.org_id)
∪ permissions_from_scoped_assignments(person, context.scope)
If a person has workspace.resources:view from their org membership and workspace.resources:manage from a workspace-scoped assignment, they can manage resources in that workspace.
For service accounts:
effective_permissions(service_account, context) =
permissions_from_scoped_assignments(service_account, context.scope)
Service accounts have no org membership baseline. Their permissions come entirely from role assignments.
For personal access tokens:
effective_permissions(token, context) =
effective_permissions(token.person, context) ∩ token.scopes
When scopes is NULL, the PAT has the person's full permissions. When set, the intersection narrows the token's capabilities.