Skip to content

内部実装

コミット 10087e7 のソースを読んだもの。ここでの主張はすべてファイルと行を指す。

コードマップ

パス責務
internal/eventstore/イベントストア中核: PushFilterFilterToReducerSearchevents2 テーブルが正本
internal/command/書き込み側ユースケース。ドメイン操作を write model へ reduce し整合性チェック後に push
internal/query/読み取り側。projection を SQL テーブルへ materialize して提供
internal/api/API 層: grpc/http/authz/。単一サービス定義から 3 トランスポート
internal/api/authz/トークン検証と permission チェック
cmd/cobra コマンド (startsetupinitialisemirrorkey)。cmd/zitadel.go がルート
backend/v3/進行中の次世代バックエンド (storage・api・instrumentation)

中核データ構造

Command interface はイベントを書き込む「意図」(internal/eventstore/event.go:28)。action を埋め込み (Aggregate()Creator()Type()Revision() を要求、event.go:16)、加えて Payload() any (nil / struct / JSON の []byte)、UniqueConstraints()、検索用 index を宣言する Fields() を持つ。

Event interface は保存済みアクティビティ (event.go:55)。Sequence() uint64 (aggregate 内連番)、CreatedAt()Position() decimal.Decimal (グローバル順序)、Unmarshal(ptr any) を持つ。

Aggregate 構造体はテナント識別子をモデルに強制する (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"`
}

ResourceOwnerInstanceIDNewAggregate で context から自動的に埋められる (aggregate.go:20)。どの書き込みもテナントを省略できない。

認可側では CtxDataMembershipRoleMapping が認可コンテキストを運ぶ。membership (org/project の role) を permission 文字列へ変換するのが internal/api/authz/permissions.go 全体だ。SystemUserPermissions は system user 用の変種で、slices.Sortslices.Compact で重複除去する。

追う価値のあるパス

permission 解決は membership を、リクエストが照合される permission 文字列へ変換する。入口は getUserPermissions (internal/api/authz/permissions.go:25)。呼び出し元が system user なら system membership が直接マップされる (permissions.go:33)。そうでなければ membership を取得し、0 件なら継承込みで一度だけ再取得してから諦める:

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")
    }

各 membership の role は mapMembershipToPerm で permission へ展開される (permissions.go:98)。project / project-grant の role には project の ObjectID が context として付与され、project.write:123 の形になる (permissions.go:120):

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

判定は checkUserPermissions が行う (internal/api/authz/authorization.go:60)。permission ゼロなら deny (AUTH-5mWD2)。auth option に CheckParam がなければ任意の permission で許可 (global)。そうでなければ、context ID なしの permission を持つ (HasGlobalPermission、authorization.go:111) か、req のフィールド値が permission の context ID と一致する (hasContextPermission、authorization.go:88) ときに許可する。req フィールドは reflection で引く (getFieldFromReq、authorization.go:103)。

呼び出しチェーン:

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

permission モデルは "<verb>:<resourceID>" 文字列のリストにすぎない。RBAC は role から permission へのマッピングテーブルで、resource scoping は : の後ろの文字列一致で判定する。専用の policy DSL は持たない。

読んで驚いた点

Push はアプリ層でロックを取らない。イベントの primary key は実質 (instance, aggregate, sequence) なので、並行 writer が同じ sequence を取ると PostgreSQL が events2_pkey 違反 (SQLSTATE 23505) を返し、コードはトランザクション全体を maxRetries 回までリトライする (internal/eventstore/eventstore.go:133)。制約名はチェックにハードコードされている:

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
}

CockroachDB の CR000 と serialization failure 40001 も依然ここで扱われる (eventstore.go:148)。v3 以前の CockroachDB 時代の名残だ。コメントには issue #7202 が紐づく。直列化は完全に DB とリトライループに委ねられ、アプリの協調処理ではない。

非自明な選択がもう 2 つ。イベントは projection を待たずフィールドで lookup できる: Command.Fields() (event.go:39) が宣言した値が専用の field index テーブルへ書かれ、Eventstore.Search で読める (eventstore.go:184)。そしてグローバル順序は単調 bigint でなく shopspring/decimalPosition を使う (event.go:63)。トランザクション間の順序を安定させつつ、既存イベントの間に position を挿入する余地を残すためだ。