@nyuro/rbac
v0.3.0
Published
Nyuro authorisation: permission catalogue, snapshot decider, route policy DSL and table matcher (.), Express policy router and introspection (./express).
Readme
@nyuro/rbac
Authorisation for Nyuro products: a permission catalogue, a fail-closed snapshot decider, a route policy DSL with an exact table matcher, and Express bindings.
| Import | For |
|---|---|
| @nyuro/rbac | Framework-neutral core: catalogue, evaluation, decider, policy DSL, route policy table |
| @nyuro/rbac/express | policyRouter, policy middleware, edge gate, stack introspection (Express 4 or 5) |
express is an optional peer dependency. The /express entry never imports it: it works on
structural types, so its declarations need neither express nor @types/express.
Catalogue
import { defineCatalogue } from '@nyuro/rbac'
export const catalogue = defineCatalogue([
{ name: 'chatflows:view', category: 'chatflows', scope: 'workspace', apiKeyAllowed: true },
{ name: 'chatflows:update', category: 'chatflows', scope: 'workspace', apiKeyAllowed: true },
{ name: 'evaluations:run', category: 'evaluations', scope: 'workspace', apiKeyAllowed: true, feature: 'evaluations' },
{ name: 'workspace:update', category: 'workspace', scope: 'workspace', apiKeyAllowed: false }
])
await platform.raw.PUT('/authz/products/{product}/permissions', {
params: { path: { product: 'flow' } },
body: { permissions: catalogue.toDefinitions('flow') } // names become flow:chatflows:view
})Rules, applied whenever a catalogue is supplied:
- Implies. A non-view permission implies
<category>:viewwhen that permission exists.implies: [...]overrides it ([]implies nothing); closure is transitive. Unknown targets throw. - API keys. A permission with
apiKeyAllowed: falseis never satisfied for akind: 'apiKey'principal, even when the key holds it, and contributes nothing throughimplies. - Features. A permission with
featurecounts only whenprincipal.features[feature] === true. - Unknown names are never satisfied (a typo denies rather than allows).
Without a catalogue, names compare literally.
Deciding
import { createSnapshotDecider } from '@nyuro/rbac'
import { createPlatformClient } from '@nyuro/sdk/platform'
const decider = createSnapshotDecider({ product: 'flow', catalogue, remote: createPlatformClient({ ... }) })
await decider.decide(principal, ['chatflows:create', 'chatflows:update'], 'any')
await decider.decide(principal, ['chatflows:view'], 'all', { resource: { type: 'chatflow', id } })- Decisions are local, from
principal.permissions(the snapshot identity resolve returns). Snapshot entries may be bare (chatflows:view) or namespaced (flow:chatflows:view); entries for other products are ignored. - A check that names a
resourcegoes toPOST /platform/v1/authz/decide(resource grants live there) with names namespaced for the product, after the local hard rules (unknown name, API-key restriction, feature gate) pass. Withoutremote, resource checks are decided locally. - Fail closed.
decidenever throws and never allows on error: a failure is{ allowed: false, unavailable: true }(reported toonError). HTTP layers answer 503 for it. decide(principal, required, mode)has the call shape ofPermissionPort.decidein@nyuro/flow-ports, and the flow-ports and contractPrincipaltypes satisfyRbacPrincipal.
Route policies
import { pub, flow, session, perm, createPolicyTable } from '@nyuro/rbac'
pub() // anyone
flow() // anonymous at the edge; the controller enforces the flow's own policy
session() // any authenticated principal
perm('tools:view') // all of these (default)
perm('chatflows:create', 'chatflows:update', { mode: 'any' })
perm('chatflows:view', { resource: (req) => ({ type: 'chatflow', id: req.params.id }) })
const table = createPolicyTable([
{ method: 'GET', path: '/api/v1/ping', policy: pub() },
{ method: 'POST', path: '/api/v1/prediction/:id', policy: flow() },
{ method: 'GET', path: ['/api/v1/chatflows', '/api/v1/chatflows/:id'], policy: perm('chatflows:view') }
])
table.isAnonymousAllowed('GET', '/api/v1/ping') // true
table.isAnonymousAllowed('GET', '/api/v1/ping/') // false: one spelling per pathThe matcher is exact and segment-wise (no regular expressions):
- No prefix matching:
/pingdoes not match/ping/xor/pingx. - Case-sensitive. Pair it with
app.set('case sensitive routing', true). - Requests with
..,.,//, a trailing slash, an encoded dot/slash/backslash or a backslash never match, so they are never anonymous. Patterns with those throw at construction. - Pattern syntax: literals,
:name,:name?(last segment),*name(last segment, one or more segments). Regex params and RegExp paths are rejected. ALLrows match every method; HEAD also matches GET rows (as Express routes them).- The most specific row wins
match()(literal > param > optional > wildcard).isAnonymousAllowedis true only when a row matches and every matching row ispuborflow. - Duplicate (method, pattern) rows throw unless their policies are equivalent.
perm() validates names at definition time and rejects commas, so
checkAnyPermission('a,b') becomes perm('a', 'b', { mode: 'any' }).
Express
import express from 'express'
import { createPolicyMiddleware, createEdgeGate, policyRouter, listRoutes, policyTableFromRoutes, undeclaredRoutes } from '@nyuro/rbac/express'
const enforce = createPolicyMiddleware({ decider, getPrincipal: (req) => req.principal })
const router = policyRouter(express.Router(), { enforce })
router.get(['/', '/:id'], perm('tools:view'), toolsController.get)
router.get('/x', toolsController.get) // throws: no policy declared
const api = policyRouter(express.Router(), { enforce })
api.use('/tools', router) // mount path recorded for introspection
const table = policyTableFromRoutes(listRoutes(api, { prefix: '/api/v1' }))
app.set('case sensitive routing', true)
app.use(identify) // sets req.principal or 401s bad credentials
app.use(createEdgeGate({ table, getPrincipal: (req) => req.principal }))
app.use('/api/v1', api)policyRouterwraps every HTTP verb method andallon the instance: the policy is the first argument after the path and is mandatory;route()is disabled; paths must be canonical strings or arrays of them. The enforcing middleware runs before the handlers.- The policy middleware:
pub/flowpass;sessionneeds a principal (401);permneeds a principal (401) and an allowing decision (403); a thrown orunavailabledecision is 503. Bodies are{ error, code }; override withrespond.onDecisionobserves perm decisions. createEdgeGatelets a request without a principal through only when the table allows the exact (method, path) anonymously; it readsreq.originalUrlwithout the query by default.listRoutes(router, { prefix })walks the Express 4/5 stack and returns every{ method, path, policy, mountKnown }. Handlers registered outside a policy router havepolicy: undefined; nested routers mounted with a plainusehavemountKnown: false. A route-classification test assertsundeclaredRoutes(listRoutes(...))is empty and can snapshotformatRoutes(...).
