RBAC roles & bindings

Nanosync RBAC has three concepts: permissions (a verb on a resource type), roles (a named bag of permissions), and bindings (subject + role + scope).

A subject is a user or service account. A scope is one of org, workspace, or project. A binding grants a role at a scope, and the grant applies to every descendant (scope containment).


Permission matrix

5 verbs × 14 resource types. The cells marked ✓ are the only meaningful combinations; the others have no semantic meaning (e.g. you can’t run a workspace).

Resourcereadwritedeleterunadmin
org
workspace
project
pipeline
connection
run✓ (cancel)
log
audit
role✓ (bind)
user
token
plugin
idp
secret

Why run exists: starting a pipeline is operationally distinct from editing its YAML. On-call engineers should be able to restart a stuck pipeline at 3am without holding the keys to change its config.

Why admin is a meta-verb: (admin, pipeline) grants every other verb on pipelines. (admin, org) is god-mode at that scope. Avoids modeling an explicit role hierarchy.


Built-in roles

Four roles ship seeded into every database. They’re immutable — the API rejects modifications.

admin

editor

operator

viewer

audit access is intentionally NOT in editor or viewer. Reading audit logs is a separate decision from reading pipeline config — give it to operator and to explicit auditor bindings only.


Scope containment

A binding at a broader scope applies to everything inside it.

Alice ── editor @ workspace=analytics

                                ├─ project=prod         ← Alice can edit pipelines here
                                └─ project=staging      ← and here

Bob   ── editor @ project=prod
                                                        ← Bob can edit pipelines here only
                                                          (not staging — different project)

The reverse does not hold. A binding at a narrow scope does NOT escalate to its parents. An admin binding at project=prod does not let you create new workspaces in the org.

This matches the GCP IAM mental model. It’s intentional: prevents accidental escalation, makes “what can Alice do?” computable from her binding set without traversing a graph.


Binding roles via the CLI

Most bindings use one of the four built-in roles:

# Make alice@acme.com an admin at the org level
ns role bind admin \
  --user=alice@acme.com \
  --scope=org \
  --scope-id=00000000-0000-0000-0000-000000000001

# Make bob@acme.com an editor in the analytics workspace
ns role bind editor \
  --user=bob@acme.com \
  --scope=workspace \
  --scope-id=<workspace-uuid>

# Make oncall-svc (a service account) operator on the prod project
ns role bind operator \
  --service-account=oncall-svc \
  --scope=project \
  --scope-id=<project-uuid>
# List all bindings for a user
ns role bindings --user=alice@acme.com

# Show one binding
ns role binding show <binding-id>

# Remove a binding
ns role unbind <binding-id>

Custom role recipes

When the four built-ins don’t fit, create a custom role with exactly the verbs you need.

Data engineer for one project

A senior engineer who owns pipelines in a single project end-to-end:

ns role create \
  --name=data-engineer \
  --scope=project \
  --description='Owns pipelines + connections in this project' \
  --permission=read:pipeline \
  --permission=write:pipeline \
  --permission=delete:pipeline \
  --permission=run:pipeline \
  --permission=read:connection \
  --permission=write:connection \
  --permission=delete:connection \
  --permission=read:run \
  --permission=delete:run \
  --permission=read:log \
  --permission=read:audit

# Then bind to a specific user + project
ns role bind data-engineer \
  --user=charlie@acme.com \
  --scope=project \
  --scope-id=<project-uuid>

Audit reader

For SOC 2 / SOX reviewers who only need read-only audit access workspace-wide:

ns role create \
  --name=audit-reader \
  --scope=workspace \
  --description='Read-only access to audit logs and pipeline state' \
  --permission=read:audit \
  --permission=read:log \
  --permission=read:run \
  --permission=read:pipeline

Connector author

For someone managing the plugin marketplace:

ns role create \
  --name=connector-author \
  --scope=org \
  --description='Publish + manage marketplace plugins' \
  --permission=read:plugin \
  --permission=write:plugin \
  --permission=admin:plugin

Token manager

For an automated system that mints API tokens on behalf of users:

ns role create \
  --name=token-manager \
  --scope=org \
  --description='Mint and revoke API tokens for other users' \
  --permission=read:token \
  --permission=write:token \
  --permission=delete:token \
  --permission=read:user

How Enforce resolves a request

For request POST /v1/pipelines from Alice in project prod:

  1. The middleware resolves Alice’s Principal from her session cookie.
  2. The handler resolves the target scope from X-Project-ID (or defaults to the seeded project).
  3. rbac.Enforce(ctx, alice.UserID, VerbWrite, ResourceRef{Pipeline, scope}) runs.
  4. The enforcer walks Alice’s bindings, keeps the ones whose scope_id is an ancestor (or equal to) the request scope.
  5. For each kept binding, it loads the role’s permissions.
  6. Allow if any role has (write, pipeline) OR (admin, pipeline) OR (admin, org).
  7. Deny otherwise → 403 Forbidden, audit event recorded.

The enforcer’s permission cache is per-subject, TTL 30s, bust-on-write. Single-node behavior is correct; multi-node cache invalidation lands in v2.


Reading effective permissions

GET /v1/auth/me returns the principal’s resolved permission set for the active scope:

curl -fsSL https://nanosync.example.com/v1/auth/me \
  -H "Authorization: Bearer $TOKEN"
{
  "user_id": "…",
  "email": "alice@acme.com",
  "active_workspace": "analytics",
  "permissions": [
    {"verb": "read",   "resource": "pipeline"},
    {"verb": "write",  "resource": "pipeline"},
    {"verb": "delete", "resource": "pipeline"},
    {"verb": "run",    "resource": "pipeline"},
    {"verb": "read",   "resource": "connection"}
  ]
}

The web UI uses this to gate menu items via the <PermissionGate> component — buttons grey out instead of returning 403 on click.


What’s next