Skip to content

Internals

Read from the source at commit 10087e7. Every claim here points at a file and line.

Code map

PathResponsibility
internal/eventstore/Event store core: Push, Filter, FilterToReducer, Search; the events2 table is the source of truth
internal/command/Write-side use cases; reduce a domain operation to a write model, check consistency, then push events
internal/query/Read side; materialize projections into SQL tables and serve reads
internal/api/API layer: grpc/, http/, authz/; three transports from one service definition
internal/api/authz/Token verification and permission checks
cmd/Cobra commands (start, setup, initialise, mirror, key); cmd/zitadel.go is the root
backend/v3/In-progress next-generation backend (storage, api, instrumentation)

Core data structures

The Command interface is the intent to write an event (internal/eventstore/event.go:28). It embeds action (which requires Aggregate(), Creator(), Type(), Revision(), event.go:16) and adds Payload() any (nil, a struct, or JSON bytes), UniqueConstraints(), and Fields() for declaring values to index.

The Event interface is a stored activity (event.go:55). It carries Sequence() uint64 (per-aggregate counter), CreatedAt(), Position() decimal.Decimal (global ordering), and Unmarshal(ptr any).

The Aggregate struct forces tenant identity into the model (internal/eventstore/aggregate.go:79):

type Aggregate struct {
    ID string `json:"id"`
    Type AggregateType `json:"type"`
    // ResourceOwner is the org this aggregates belongs to
    ResourceOwner string `json:"resourceOwner"`
    // InstanceID is the instance this aggregate belongs to
    InstanceID string `json:"instanceId"`
    Version Version `json:"version"`
}

ResourceOwner and InstanceID are filled automatically from context in NewAggregate (aggregate.go:20), so no write can omit its tenant.

On the authorization side, CtxData, Membership, and RoleMapping carry the auth context: memberships (org/project roles) are translated into permission strings, and the entire internal/api/authz/permissions.go is that translation. SystemUserPermissions is the system-user variant, deduplicated with slices.Sort and slices.Compact.

A path worth tracing

Permission resolution turns memberships into the permission strings the request is checked against. It starts in getUserPermissions (internal/api/authz/permissions.go:25). If the caller is a system user, its system memberships map directly (permissions.go:33). Otherwise it loads memberships, and if none are found, retries once with inheritance before giving up:

memberships, err := resolver.SearchMyMemberships(ctx, orgID, false)
if err != nil {
    return nil, nil, err
}
if len(memberships) == 0 {
    memberships, err = resolver.SearchMyMemberships(ctx, orgID, true)
    if len(memberships) == 0 {
        return nil, nil, zerrors.ThrowNotFound(nil, "AUTHZ-cdgFk", "membership not found")
    }

The roles on each membership are expanded into permissions by mapMembershipToPerm (permissions.go:98). For project and project-grant roles, the project's ObjectID is appended as a context, producing strings like project.write:123 (permissions.go:120):

func addRoleContextIDToPerm(perm, roleContextID string) string {
    if roleContextID != "" {
        perm = perm + ":" + roleContextID
    }
    return perm
}

checkUserPermissions then decides (internal/api/authz/authorization.go:60). Zero permissions is a deny (AUTH-5mWD2). If the auth option has no CheckParam, any permission suffices (global). Otherwise it allows when the user holds a permission with no context ID (HasGlobalPermission, authorization.go:111) or when a request field matches a permission's context ID (hasContextPermission, authorization.go:88). The request field is pulled by reflection (getFieldFromReq, authorization.go:103).

The call chain:

AuthorizationInterceptor (auth_interceptor.go:16)
  -> CheckUserAuthorization (authorization.go:24)
     -> VerifyTokenAndCreateCtxData (authorization.go:28)
     -> getUserPermissions (permissions.go:25)
        -> mapMembershipsToPermissions -> addRoleContextIDToPerm (permissions.go:120)
     -> checkUserPermissions (authorization.go:60)
        -> HasGlobalPermission / hasContextPermission

The permission model is just a list of "<verb>:<resourceID>" strings. RBAC is a role-to-permission mapping table, and resource scoping is a string match on the part after the colon. There is no policy DSL.

Things that surprised me

Push does not lock at the application level. The event primary key is effectively (instance, aggregate, sequence), so two concurrent writers that pick the same sequence cause a PostgreSQL events2_pkey violation (SQLSTATE 23505), and the code retries the whole transaction up to maxRetries times (internal/eventstore/eventstore.go:133). The constraint name is hardcoded in the check:

if pgErr.ConstraintName == "events2_pkey" && pgErr.SQLState() == "23505" {
    logging.WithError(ctx, err).Info("eventstore push retry")
    continue
}
if pgErr.SQLState() == "CR000" || pgErr.SQLState() == "40001" {
    logging.WithError(ctx, err).Info("eventstore push retry")
    continue
}

The CockroachDB CR000 and serialization-failure 40001 codes are still handled here (eventstore.go:148), a leftover from the pre-v3 CockroachDB era. The comment links issue #7202. Serialization is pushed entirely onto the database and a retry loop, not application coordination.

Two more non-obvious choices. Events can be looked up by field before any projection is built: values declared by Command.Fields() (event.go:39) are written to a dedicated field-index table and read through Eventstore.Search (eventstore.go:184). And global ordering uses shopspring/decimal for Position rather than a monotonic bigint (event.go:63), which keeps cross-transaction ordering stable while leaving room to insert positions between existing events.