RBAC for Humans and Machines
Most git platforms model permissions around users. A user belongs to a team, a team has access to repositories, the user can push. This works until you add CI.
Wave CI needs to push build status to Codex. A deployment script needs to create release tags. An automated process needs to clone repositories. These aren’t users. They’re machines. And they need the same authorization guarantees as humans: the CI server should be able to push to the artifact repo but not delete branches on the main product. The deployment script should be able to tag releases but not force-push to main.
Most platforms handle this by creating bot users, regular user accounts with machine-generated passwords. It works, but it’s a hack. Bot users consume seats. They show up in audit logs as “people.” They have passwords that need rotation. And the permission model doesn’t distinguish between “a human developer who might make mistakes” and “an automated process that should have precisely scoped access.”
Entity-based permissions
Codex’s RBAC system uses the concept of an “entity,” which is either a User or a ServiceAccount. Both include the same HasPermissions concern. Both can own access tokens and SSH keys. Both appear in audit logs with their actual type. The permission system doesn’t know or care whether a request comes from a person or a machine.
Service accounts are created explicitly with a name and description. They authenticate via access tokens (prefixed, SHA-256 hashed, with optional expiry) or SSH keys. They can’t log in interactively. No password, no session, no UI access. They exist solely to act on behalf of automated processes.
The YAML-driven model
Permissions are defined in config/rbac.yml as a resource hierarchy:
resources:
project:
levels: [browse, read, write, create_repository, admin]
grants:
read:
- project:$self:browse
- repository:$self/*:read
write:
- project:$self:read
- repository:$self/*:write
children:
repository:
levels: [browse, clone, pull, read, push, write, admin]
grants:
read:
- repository:$self:pull
pull:
- repository:$self:clone
clone:
- repository:$self:browse
The grants section is the core idea. When you grant project:MYPROJ:write, the system expands that into all implied permissions: project:MYPROJ:read, project:MYPROJ:browse, repository:MYPROJ/*:write, repository:MYPROJ/*:push, and so on down the chain. The $self placeholder resolves to the current resource’s identifier. $self/* means all children.
This means permission grants are concise. You say “this service account has write access to the INFRA project” and the system derives that it can push to all repositories in INFRA, clone them, browse them, and read their content. You don’t maintain a flat list of dozens of individual permissions.
Three sources of permissions
An entity’s effective permissions come from three places:
- Direct grants: permissions assigned directly to the user or service account
- Role-based: permissions from the entity’s assigned role (Admin, Developer, Viewer, etc.)
- Team-based: permissions from all teams the entity belongs to, including each team’s role
These are combined at check time. If any source grants the required permission, access is allowed. The PermissionMinimizer computes the smallest equivalent set for display purposes, so the “effective permissions” view shows what an entity can actually do, deduplicated and with redundant grants removed.
Permission format
Every permission is a three-part string: resource:identifier:action. Examples:
repository:MYPROJ/backend:push: can push to the backend repo in MYPROJproject:MYPROJ:admin: admin on the MYPROJ project (implies everything below it)codex:users:manage: can manage user accounts*:*:*: superuser (matches everything)
The identifier supports wildcards: repository:MYPROJ/*:read grants read on all repos in a project. The expansion system handles cascade: repository:MYPROJ/backend:write implies push implies pull implies clone implies browse.
Why this matters for CI integration
When Wave CI pushes build status to Codex, it authenticates with a service account’s access token. That token has scoped permissions. It can update build results on specific repositories but can’t modify branches or merge PRs. The audit log shows “ServiceAccount:wave-ci updated build status on MYPROJ/backend,” not “Bot User updated build status.”
When a deployment script tags a release, it uses a different service account with different permissions: can create tags on production repositories, can’t push code. Separate accounts, separate audit trails, separate permission scopes.
This is what user-based auth can’t express cleanly. The machine actors in our workflow have specific, narrow responsibilities. They need permissions that match those responsibilities, no more, no less. Treating them as first-class entities instead of pretend-users makes the whole system more auditable and more secure.