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 15 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 contextsRepo-scoped
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
environmentsEnvironment definitions and approval policiesGlobal
ci_trustCI trust level management and security approvalsGlobal
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
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 resources (runs, workflows, secrets) are filtered by repo glob patterns defined on each role. A role with pattern myorg/backend-* only grants access to runs, workflows, and secrets for repositories matching that pattern.

Enforcement:

  • computeEffectivePermissions() merges repo patterns from all assigned roles using union semantics (deduplicated). If any role has *, the effective pattern is ['*'] (unrestricted).
  • The repoPatterns array is stored on the HTTP request context alongside effectivePermissions.
  • List endpoints (e.g., GET /runs): resolve allowed repos via resolveAllowedRepos() and add a WHERE repo_identifier IN (...) filter to the query. This ensures pagination counts are correct.
  • Single-resource endpoints (e.g., GET /runs/:runId): check matchesRepoPattern() after fetching the resource, returning 403 if the repo doesn’t match.
  • API keys and service accounts always get ['*'] — repo scoping applies only to role-based human users.
  • Pattern matching uses picomatch (same library as orchestrator environments).

Global resources (api_keys, webhook_sources, org_settings, members, billing, audit, environments, ci_trust, webhook_endpoints, event_log, event_dlq, support) 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.

Organizations can create unlimited custom roles. Each role has:

  • Name — unique within the organization (max 100 characters)
  • Description — optional (max 500 characters)
  • Permission matrix — 15 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
  • All 15 resources set to admin
  • 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 and support 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). Several permissions added alongside the read-attribution and admin-surface expansion features:

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 (moved from /api/v1/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)

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. event_log.read_payload, event_dlq.manage, run.cancel, secret.reveal, and scheduled_job.trigger 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, or fire a periodic job out-of-band) and is not appropriate for a read-only auditor role.

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.