Skip to content

Role-based access control (RBAC)

KiCI uses an in-house RBAC system for all authorization decisions. The OIDC issuer handles authentication only (login, user creation, email invites). All permission data lives in the KiCI database, giving operators full control over access policies without external dependencies.

Every organization in KiCI has a set of roles. Each role defines a permission matrix mapping 18 resources to 5 access levels. Users can have multiple roles assigned simultaneously — their effective permissions are computed as the union (most permissive wins) across all assigned roles.

User -> [Role A, Role B, Role C] -> merge(permsA, permsB, permsC) -> Effective Permissions

This additive model means roles only grant access — there are no deny rules. Adding a role can never reduce a user’s permissions.

ResourceDescriptionScope
runsWorkflow runs, jobs, steps, logsRepo-scoped
workflowsWorkflow definitions and lock filesRepo-scoped
secretsEncrypted secret values and contextsGlobal
api_keysUser API keys, orchestrator keys, and service accountsGlobal
webhook_sourcesWebhook source registration and secretsGlobal
org_settingsOrganization display name, configurationGlobal
membersMember management, roles, invitationsGlobal
billingPlan management, checkout, subscriptionsGlobal
auditAudit log viewing (read-only resource)Global
contextsDeployment context definitions, variables, and protection rules; write is required to approve or reject a reviewer or concurrency hold, admin to act on a timer holdGlobal
ci_trustCI trust level management; required to approve or reject a security holdGlobal
webhook_endpointsWebhook endpoint configuration and managementGlobal
event_logWebhook event log metadata and payload viewingGlobal
event_dlqWebhook event dead-letter queue (requeue, discard)Global
supportEnable/disable KiCI support sessions for the orgGlobal
teamsOperator-defined teams: membership and team-role grantsGlobal
fleetFleet management: host roster, host declare/remove, agent controlGlobal
notificationsNotification channels and subscriptions (Slack, delivery config)Global
LevelNumericDescription
none0No access (resource hidden from API responses)
read1View resource data
read_payload2event_log only: read raw webhook payload bodies (may contain PII)
write3Create and modify resources
admin4Full control including deletion and management

Levels are hierarchical: admin implies write, which implies read_payload, which implies read. A check for read access passes if the user has any level >= read. The read_payload level is meaningful only for the event_log resource (reading raw webhook bodies that may contain PII); for other resources it behaves equivalently to read.

Repo-scoped operations vs global resources

Section titled “Repo-scoped operations vs global resources”

Repo scoping is a property of each operation, not of a resource. Every developer operation declares whether it acts on a specific repository’s resource, and an operation that does must honour the caller’s repo glob patterns on every entrypoint that exposes it — the web UI and kici CLI (which share one REST API), and the developer MCP tool surface.

Repo-scoped operations:

  • Runs — listing, reading, step logs, cancel, cancel-by-branch, and re-run.
  • Workflow registrations — listing, trigger (“run now”), disable, and delete.
  • Held runs — listing the approval queue, and approving or rejecting a hold.

A role with pattern myorg/backend-* therefore sees and drives only those repositories’ runs, registrations, and held runs.

Because scoping is per operation, an operation’s repo scoping is independent of the resource its permission level is checked against. Approving a held run is gated on the permission matching the hold’s type (contexts:write, contexts:admin for a timer hold, ci_trust:write for a security hold). contexts and ci_trust are both global resources, so patterns do not narrow what a role may edit — yet the approval itself acts on one repository’s run and is therefore repo-scoped. Reading the queue is scoped for the same reason: contexts:read alone would otherwise enumerate every hold in the org, so the read would answer what acting on it refuses.

A registration is matched on the repository it was registered from. For a workflow that runs against other repositories, that authoring repository is what the patterns are checked against.

Secrets are NOT repo-scoped. Scoped secrets are keyed by context / environment on the customer’s orchestrator, so there is no repository to scope against: a role’s repo patterns do not narrow secret access. Access to secrets is governed by the permission level (secrets:read / secrets:write) together with the context the secret is bound to.

Enforcement:

  • computeEffectivePermissions() merges repo patterns from all assigned roles using union semantics (deduplicated). If any role has *, the effective pattern is ['*'] (unrestricted).
  • On the HTTP plane the repoPatterns array is stored on the request context alongside effectivePermissions. The developer MCP plane has no request context — it resolves the org per tool call — so it resolves the same patterns per call and threads them into the shared operation layer.
  • List endpoints (e.g., GET /runs, GET /registrations, GET /held-runs, and the MCP list_runs / list_workflows / cancel_runs_by_branch tools): narrow the result to the repositories the caller may see. For runs this is a WHERE repo_identifier IN (...) filter, which keeps pagination counts correct; for registrations and held runs, which the control plane relays rather than stores, the relayed set is filtered on return. A held run carries no repository of its own, so its owning run is resolved against the Platform’s mirrored execution_runs and a hold whose run has not been mirrored yet is dropped — the filter fails closed. Either way a repo-restricted caller cancelling a shared branch never reaches another repository’s runs.
  • Single-resource endpoints (e.g., GET /runs/:runId, POST /registrations/:id/trigger, POST /held-runs/:heldRunId/approve): apply the check after resolving the target’s repository, returning 403 if it does not match. The denial also writes an authz.denied audit row with target_type = 'repo', the same way an insufficient-permission denial is recorded. On the held-run approve and reject routes the repo check runs before the hold-type branch, and its refusal names neither the hold type nor the permission that type would have required — otherwise the refusal itself would tell the caller what kind of hold they are not allowed to see.
  • Single-resource MCP tools (get_run, get_step_logs, cancel_run, rerun_run, trigger_run, approve_run, reject_run): apply the same policy after resolving the target, but report the denial as an indistinguishable “not found” rather than an explicit refusal — an agent surface must not become an enumeration oracle for repositories the caller cannot see. The authz.denied audit row still records the real reason and the repository.
  • API keys and service accounts always get ['*'] — repo scoping applies only to role-based human users. This holds on both planes: a user agent PAT inherits the minting user’s patterns, while an org agent API key is unrestricted within its permission level.
  • Patterns are matched as globs: * does not cross a / separator (so org/* covers org/backend but not org/sub/deep), and matching is case-sensitive. The orchestrator’s context bindings use the same glob semantics.

Global resources (api_keys, webhook_sources, org_settings, members, billing, audit, contexts, secrets, ci_trust, webhook_endpoints, event_log, event_dlq, support, teams, fleet, notifications) are governed by permission level alone — repo patterns do not apply.

Every role has at least one repo pattern. The default pattern * matches all repositories.

Patterns are an allow-list — there is no deny semantic

Section titled “Patterns are an allow-list — there is no deny semantic”

A role’s repo patterns only ever grant access — the model carries no deny semantic. Patterns from all assigned roles are unioned, so a deny in one role could not be reconciled with an allow in another. To restrict a role, list the repositories it may reach.

Four negation forms would turn a restriction into a grant. A leading !!myorg/secret-keys — expands to “every repository except myorg/secret-keys”. The extglob complement !( … ) does the same wherever it appears in the pattern, so myorg/!(secret-keys) expands to “every repository under myorg/ except secret-keys” — narrower, and the same defect. The negated character class [^ … ] does it one character at a time: myorg/[^s]* expands to “every repository under myorg/ whose name does not begin with s”, excluding the one repository it names and admitting the rest. The negative assertions (?! … ) and (?<! … ) are the widest, because each can spell a whole repository identifier rather than one character: (?!myorg/secret-keys)** expands to “every repository in every organization except myorg/secret-keys”. Both layers refuse all four forms:

  • At write time, a pattern that begins with !, or contains !(, [^, (?! or (?<!, is rejected. This applies to the role create and update endpoints (HTTP 400), to kici-platform-admin role create --repo-patterns (non-zero exit, nothing written), and to the shared role writer itself, so no future write path can skip the rule.
  • At match time, such a pattern matches no repository. Nothing is granted through it, so it cannot widen access.

The other extglob heads — *( … ), +( … ), @( … ), ?( … ) — do not complement and are accepted. So are the positive assertions (?= … ) and (?<= … ), a non-capturing group (?: … ), and a plain group, each of which grants only what it names. So is the bracket form [! … ], which is not the POSIX negation here: the matcher reads it as a literal class containing ! and the characters listed, so myorg/[!s]* admits exactly the repositories whose name begins with ! or s — a genuine restriction rather than a grant.

A ! that is part of a repository name — myorg/we!rd — is matched literally, so such a repository still matches normally.

Repository scope is part of the grant ceiling

Section titled “Repository scope is part of the grant ceiling”

Repository scope is a privilege in its own right, so a member cannot hand out a scope wider than the one they hold. Every path that writes or grants a role runs the containment rule below: role create, role update, assigning a role to a member, granting a role to a team, and inviting a member into a role. The check is independent of the permission-level ceiling, so widening a role’s patterns is refused even when the request changes no permission at all.

The rule is deliberately conservative. A pattern is grantable when:

  • the caller’s own scope is * (they may grant anything), or
  • the caller already holds that exact pattern, or
  • the request names one concrete repository that the caller’s own patterns already reach.

Everything else is refused with 403, naming the pattern. That includes * requested by a scoped caller, a repository outside the caller’s patterns, and a glob the caller does not hold literally — even one that is genuinely narrower, such as myorg/backend-* requested by a caller holding myorg/*. Deciding glob containment in general is not tractable, and a wrong verdict on an authorization boundary hands out access; the workaround is to grant the literal pattern instead. A caller whose scope cannot be determined grants nothing.

Creating a role with repoPatterns omitted is the same as requesting *, because that is the field’s default — so a scoped caller is refused there too.

Organization API keys carry no repository scope of their own and operate across every repository within their permission level. Minting one from a repository-restricted account would therefore launder that account into unrestricted access, so POST /orgs/:customerId/api-keys refuses with 403 for a caller whose own scope is not *. An unrestricted caller is unaffected.

The break-glass kici-platform-admin role create is exempt: it is an operator tool that runs against the database directly and has no user ceiling to measure against.

Organizations can create unlimited custom roles. Each role has:

  • Name — unique within the organization (max 100 characters)
  • Description — optional (max 500 characters)
  • Permission matrix — 18 resources x 5 levels
  • Repo patterns — array of glob patterns for scoping repo-bound resources

Users can have multiple roles. The effective permission for each resource is the maximum level across all assigned roles:

Role "Member": { runs: 'read', api_keys: 'read', members: 'read' }
Role "Deployer": { runs: 'write', api_keys: 'read', members: 'none' }
────────────────────────────────────────────────────────────────────────
Effective: { runs: 'write', api_keys: 'read', members: 'read' }

A mergePermissions() helper inside the Platform implements this union logic.

Users with no role assignments see the dashboard shell but cannot access any org data. They remain org members — to fully revoke access, remove them from the organization.

  • Immutable — cannot be edited, deleted, or renamed
  • Every resource set to its highest level: admin on 17 of the 18 resources, and write on fleet (whose gates top out at fleet:write, so write already grants full fleet control). See DEFAULT_OWNER_PERMISSIONS in permissions.ts
  • Repo pattern: *
  • Marked with is_owner = true in the database
  • Visible in the roles tab with a “Built-in” badge
  • At least one Owner must exist per organization (last-owner protection)
  • Default custom role — editable and deletable by Owners
  • All resources set to read by default, except ci_trust, support, and fleet which default to none (see DEFAULT_MEMBER_PERMISSIONS in permissions.ts)
  • Ships with every new organization
  • Assigned automatically to new members on invite acceptance

All org-scoped dashboard API routes enforce RBAC through a middleware chain:

orgContextMiddleware(db) -> requirePermission(db, resource, level) -> route handler
  1. Verifies the authenticated user is a member of the target org (for service accounts, verifies the SA’s org_id matches)
  2. Blocks disabled organizations (returns 403 with disabled_at)
  3. Blocks suspended members (returns 403)
  4. Computes effective permissions: uses API key permissions if present, otherwise calls computeEffectivePermissions() to merge the user’s assigned roles
  5. Sets effectivePermissions, isOwner, and orgRole on the request context

Factory function that creates a middleware checking a specific resource + level:

requirePermission(db, 'runs', 'write');
// Checks c.get('effectivePermissions').runs >= PERMISSION_HIERARCHY['write']

Returns a descriptive 403 error if the check fails:

{ "error": "Insufficient permission: runs.write needed" }

OR-semantics variant that passes if any of the given permission checks are satisfied. Returns 403 only when none pass:

requireAnyPermission(db, [
{ resource: 'runs', required: 'write' },
{ resource: 'org_settings', required: 'admin' },
]);
// Passes if the user has runs.write OR org_settings.admin

Permissions are checked from the database on every API request. There is no session cache to invalidate — role changes take effect immediately on the next request.

The dashboard API authenticates callers via OIDC and resolves the calling user’s org membership before evaluating permissions.

Orchestrator-side RBAC: access log and run cancel

Section titled “Orchestrator-side RBAC: access log and run cancel”

The orchestrator has its own fixed 3-role (owner / admin / auditor) RBAC model for its admin HTTP surface (packages/orchestrator/src/secrets/rbac.ts), spanning 23 fine-grained permissions. The read-attribution and admin-surface permissions are:

PermissionGranted toGuards
access_log.readowner, admin, auditorGET /api/v1/admin/access-log + GET /api/v1/admin/access-log/:id + CLI list/show
event_log.readowner, admin, auditorList/show webhook event-log metadata rows
event_log.read_payloadowner, adminRead raw webhook payload bodies (may contain PII)
event_dlq.readowner, admin, auditorList/show entries in the webhook event dead-letter queue
event_dlq.manageowner, adminRequeue or discard webhook event DLQ entries
run.cancelowner, adminPOST /api/v1/admin/runs/:runId/cancel
secret.revealowner, adminThe ?reveal=true variant of the run secret-outputs admin route (decrypts values)
scheduled_job.triggerowner, adminPOST /api/v1/admin/scheduled-jobs:name/trigger (manually fire a scheduled job)
attestation.retryowner, adminDrain / re-arm the deferred-attestation outbox
orchestrator.drainowner, adminGET/POST /api/v1/admin/orchestrator/drain (drain, resume, status)
ci_trust.readowner, adminGET /api/v1/admin/trust-policy (read the org-wide CI trust policy)
ci_trust.adminowner, adminPATCH /api/v1/admin/trust-policy (modify org-wide trust policies)

access_log.read, event_log.read, and event_dlq.read are deliberately granted to the auditor role — an auditor’s job is to read the access log, the webhook event log, and the webhook event DLQ without being able to mutate anything. The remaining permissions are restricted to owner + admin because each either discloses sensitive payload data or mutates state (read raw payload bodies that may contain PII, requeue/discard a DLQ entry, cancel a run, decrypt and disclose a stored secret value, fire a periodic job out-of-band, re-arm the deferred-attestation outbox, quiesce the coordinator, or read and change the org trust policy that decides whether a fork PR runs at all) and is not appropriate for a read-only auditor role.

The full 23-permission matrix, including the context.* / secret.* / token.manage / key.rotate permissions, is in the kici-admin CLI reference.

These permissions guard the orchestrator’s admin HTTP surface only. The Platform-side dashboard routes continue to use the Platform RBAC resources (runs:write for cancel, audit:read for the Data access tab).

  • Join — via invite acceptance (records the user as an org member with an initial role assignment)
  • Role change — Owner assigns or removes roles
  • Suspension — Owner suspends the member, which blocks all API access
  • Self-leave — member can leave unless they are the last Owner
  • Removal — Owner removes the member, which cascades to role assignments, org membership rows, and any user API keys the member created
  • The sole Owner of an org cannot leave or be removed
  • The sole Owner’s Owner role assignment cannot be removed
  • These checks run inside database transactions for consistency
  • packages/orchestrator/src/secrets/rbac.ts (in the OSS source tree) — the orchestrator’s fixed 3-role model and the permission constants used by its admin HTTP surface
  • Two-layer RBAC (operator guide) — how this control-plane RBAC relates to the orchestrator-CLI RBAC surface, and how to keep the two in sync.