@cueplusplus/access
v0.2.0
Published
CUE++ ID — the access-management model: client/user groups, the full-matrix grant, and the derived effective-access read. Framework-agnostic, server-only.
Readme
@cueplusplus/access
The access-management model for CUE++ ID:
OAuth clients bundle into client-groups, members bundle into user-groups,
and an admin grants access as a full matrix — subject {user | user_group}
× target {client | client_group}. A member's effective access is derived
from those grants across group membership; it is never stored.
This package is the spine: the entity types, the effective-access read, and the pure grant algebra live here once, so every surface (the consent gate, the dashboard, the access API, the MCP servers, the CLI) computes access the same way. It is framework-agnostic and server-only — the reader drives a service-role Supabase client, and importing this entry into a client bundle fails the build.
Backed by the migrations supabase/migrations/20260826000001–20260826000008
(the tables + derivation, the access.* producers, the hard-revoke RPC, the
cutover seed, the admin readers, and the introspection-client flag). Apply them
with the runbook in
docs/runbooks/access-management.md
— written by the build, applied by the operator, never by the build itself.
What's in this release. The full model surface: the entity types, the effective-access read (
effectiveAccessApi) and its pure TS mirror (the grant algebra), the enforcement decision (mayAuthorize), the admin write ops (accessAdminApi— create group, add member, grant, hard revoke), theaccess.*event emit (emitAccessEvent), the admin readers (adminConsentsApi/adminUsageApi/accessIntrospectionApi), and theclient_credentialsintrospection token (issueAccessToken/verifyAccessToken) with its per-client flag store (accessApiClientsApi).
Install
pnpm add @cueplusplus/accessThe effective-access read
import { effectiveAccessApi } from '@cueplusplus/access'
import { createClient } from '@supabase/supabase-js'
// A SERVICE-ROLE client, constructed per unit of work in a server handler.
// The two functions this reads are service_role-only (the auth schema they
// lean on is not exposed to PostgREST).
const sb = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
const access = effectiveAccessApi(sb)
// Every client this member may reach — one row per grant path.
const { data: mine, error } = await access.forUser({ userId })
if (error) throw error
// mine: { userId, clientId, viaGrantId, source }[] source ∈ direct | user_group | client_group | both
// Every member who may reach a client.
const { data: who } = await access.forClient({ clientId: 'dashboard' })Every call returns { data, error }; nothing throws for an expected database
error.
The grant algebra (pure)
The TypeScript mirror of the SQL derivation — no database, no I/O. Use it to expand already-loaded entities (a dashboard, a batch check) or to unit-test the rule.
import { expandGrants, collapseToPairs, grantSource } from '@cueplusplus/access'
const rows = expandGrants({
grants, // AccessGrant[] (only LIVE grants expand)
userGroupMembers, // { groupId, userId }[]
clientGroupMembers, // { groupId, clientId }[]
// now?: Date (liveness is judged against this; defaults to now)
})
// A RETIRED client is absent from these rows (issue #51) — but you have to say
// which clients those are. `expandGrants` is pure: it sees grants and
// memberships, never the registry — so pass `deletedClientIds` alongside
// `deletedUserGroupIds` / `deletedClientGroupIds`, and a retired client is
// reached by nothing, however the grant points at it. Omit them and every
// client is treated as live.
//
// The SQL `public.effective_access` needs no such argument: it reads
// `auth.oauth_clients` itself. An id with NO registry row is untouched in both,
// because `client_id` carries no foreign key by design.
// rows is the same shape public.effective_access returns: one row per
// (user, client, grant) path, de-duplicated on the full tuple.
// The multi-path picture as one row per (user, client):
const pairs = collapseToPairs(rows) // { userId, clientId, sources[], viaGrantIds[] }
grantSource('user_group', 'client_group') // 'both'source is a property of ONE grant: a (user, client) pair reachable through
several grants carries several sources — collapseToPairs gathers them.
License
MIT — see LICENSE.
