Merge commit from fork

* Use slices.Contains over custom function

* Correctly remove roles from granted roles

* fix(setup): repair user grants with stale roles (GHSA-v859-c572-qh5p)

Add setup step 73 that reconciles existing user grants whose roles were
left too broad by the buggy cascade removal in removeRoleFromUserGrant.
The corruption lives in the eventstore event payloads, so the step pushes
a corrective user.grant.cascade.changed event per affected grant (roles
intersected with the currently valid set) and re-triggers the user grant
projection. Runs in the second setup slice, after the projection tables
it reads have been created.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(setup): scope GHSA-v859-c572-qh5p repair to grant-based user grants

Direct user grants can never be hit by this bug (only ChangeProjectGrant's
multi-role cascade to grant-based grants can trigger it), so drop the
direct-grant branch from the finder query to avoid stripping unrelated,
legitimate roles that merely mismatch for other reasons (e.g. stale
role_key drift). Also exclude removed instances from the migration scope,
and log the number of grants fixed per instance.

Co-authored-by: Cursor <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Marco A.
2026-07-09 13:28:09 +02:00
committed by GitHub
co-authored by Cursor Claude Opus 4.8 Livio Spring
parent 8b9d3637fd
commit 9d60e83d6f
11 changed files with 394 additions and 33 deletions
+134
View File
@@ -0,0 +1,134 @@
package setup
import (
"context"
_ "embed"
"fmt"
"github.com/zitadel/zitadel/backend/v3/instrumentation/logging"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/usergrant"
)
var (
//go:embed 73.sql
fixUserGrantRoles string
)
// FixUserGrantRoles repairs grant-based user grants that kept roles which
// should have been cascade-removed (GHSA-v859-c572-qh5p). Only grant-based
// user grants (ug.grant_id set) are in scope: the bug can only manifest via
// ChangeProjectGrant's multi-role cascade to those grants (see 73.sql for
// details), so direct user grants are intentionally left untouched even if
// their roles also happen to mismatch, since that mismatch would be unrelated
// pre-existing data drift, not this CVE. The corruption lives in the
// eventstore event payloads, so we fix the source of truth by pushing a
// corrective user.grant.cascade.changed event with the reconciled role set,
// then let the projection catch up.
type FixUserGrantRoles struct {
eventstore *eventstore.Eventstore
}
func (mig *FixUserGrantRoles) Execute(ctx context.Context, _ eventstore.Event) error {
instances, err := mig.eventstore.InstanceIDs(
ctx,
eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs).
OrderDesc().
AddQuery().
AggregateTypes(instance.AggregateType).
EventTypes(instance.InstanceAddedEventType).
Builder().ExcludeAggregateIDs().
AggregateTypes(instance.AggregateType).
EventTypes(instance.InstanceRemovedEventType).
Builder(),
)
if err != nil {
return err
}
ctx = authz.SetCtxData(ctx, authz.CtxData{UserID: "SETUP"})
for i, instanceID := range instances {
ctx = authz.WithInstanceID(ctx, instanceID)
logging.Info(ctx, "fix user grant roles", "instance", instanceID, "migration", mig.String(), "progress", fmt.Sprintf("%d/%d", i+1, len(instances)))
// The finder reads projection tables, which may not yet be populated during
// setup. Bring the sources up to date for this instance before querying.
for _, source := range []*handler.Handler{
projection.ProjectGrantProjection,
projection.UserGrantProjection,
} {
if _, err = source.Trigger(ctx); err != nil {
return err
}
}
fixedCount, err := mig.fixInstanceUserGrants(ctx, instanceID)
if err != nil {
return err
}
if fixedCount == 0 {
continue
}
logging.Info(ctx, "fixed user grant roles", "instance", instanceID, "migration", mig.String(), "grantsFixed", fixedCount)
// Reflect the corrective events we just pushed in the read model.
_, err = projection.UserGrantProjection.Trigger(ctx)
logging.OnError(ctx, err).Debug("failed triggering user grant projection to update roles")
}
return nil
}
func (mig *FixUserGrantRoles) fixInstanceUserGrants(ctx context.Context, instanceID string) (fixedCount int, err error) {
var correctedGrants []eventstore.Command
tx, err := mig.eventstore.Client().BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
_ = tx.Rollback()
return
}
err = tx.Commit()
}()
rows, err := tx.QueryContext(ctx, fixUserGrantRoles, instanceID)
if err != nil {
return 0, err
}
defer rows.Close()
for rows.Next() {
var (
id string
resourceOwner string
correctRoles = database.TextArray[string]{}
)
if err := rows.Scan(&id, &resourceOwner, &correctRoles); err != nil {
return 0, err
}
aggregate := &eventstore.Aggregate{
ID: id,
Type: usergrant.AggregateType,
Version: usergrant.AggregateVersion,
ResourceOwner: resourceOwner,
InstanceID: instanceID,
}
correctedGrants = append(correctedGrants, usergrant.NewUserGrantCascadeChangedEvent(ctx, aggregate, correctRoles))
}
if rows.Err() != nil {
return 0, rows.Err()
}
_, err = mig.eventstore.PushWithClient(ctx, tx, correctedGrants...)
return len(correctedGrants), err
}
func (*FixUserGrantRoles) String() string {
return "73_fix_user_grant_roles"
}
+39
View File
@@ -0,0 +1,39 @@
-- Finds user grants whose stored roles include roles that are no longer valid
-- (the residue left by the buggy cascade removal, GHSA-v859-c572-qh5p), and
-- returns the corrected role set for each. $1 = instance_id, run once per instance.
--
-- Scope: only grant-based user grants (ug.grant_id set) can be affected by this
-- bug. It was introduced by ChangeProjectGrant (internal/command/project_grant.go),
-- which cascades a *multi*-role removal to the user grants tied to that project
-- grant. Direct user grants never go through a multi-role removal call, so any
-- role mismatch there is unrelated to this CVE (e.g. pre-existing role_key drift)
-- and must not be "fixed" here to avoid stripping unrelated, legitimate roles.
WITH computed AS (
SELECT
ug.id,
ug.resource_owner,
ug.roles AS current_roles,
-- Recompute the roles this grant is actually allowed to keep by filtering
-- its current roles down to the ones still granted by the project grant.
COALESCE(
ARRAY(
SELECT r
FROM unnest(ug.roles) AS r -- expand the stored roles array
WHERE r = ANY(pg.granted_role_keys)
),
ARRAY[]::TEXT[] -- keep NULL out; use empty array
) AS valid_roles
FROM projections.user_grants5 ug
JOIN projections.project_grants4 pg
ON pg.instance_id = ug.instance_id
AND pg.grant_id = ug.grant_id
WHERE ug.instance_id = $1
AND ug.grant_id IS NOT NULL AND ug.grant_id <> '' -- direct grants are out of scope, see above
AND ug.roles IS NOT NULL
AND cardinality(ug.roles) > 0 -- nothing to correct on empty grants
)
-- valid_roles is always a subset of current_roles, so a size mismatch means at
-- least one stale role was dropped -> this grant needs a corrective event.
SELECT id, resource_owner, valid_roles
FROM computed
WHERE cardinality(valid_roles) <> cardinality(current_roles);
+1
View File
@@ -192,6 +192,7 @@ type Steps struct {
s70AddEventStoreCommandEnforceOwner *AddEventStoreCommandEnforceOwnerColumn
s71JWTProvideAddAudienceColumn *JWTProvideAddAudienceColumn
s72AddColumnsToLoginNamesView *AddColumnsToLoginNamesView
s73FixUserGrantRoles *FixUserGrantRoles
RelationalTables *TransactionalTables
}
+2
View File
@@ -255,6 +255,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s70AddEventStoreCommandEnforceOwner = &AddEventStoreCommandEnforceOwnerColumn{dbClient: dbClient}
steps.s71JWTProvideAddAudienceColumn = &JWTProvideAddAudienceColumn{dbClient: dbClient}
steps.s72AddColumnsToLoginNamesView = &AddColumnsToLoginNamesView{dbClient: dbClient}
steps.s73FixUserGrantRoles = &FixUserGrantRoles{eventstore: eventstoreClient}
err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil)
if err != nil {
@@ -380,6 +381,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string)
steps.s68TargetAddPayloadTypeColumn,
steps.s71JWTProvideAddAudienceColumn,
steps.s72AddColumnsToLoginNamesView,
steps.s73FixUserGrantRoles,
} {
setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed")
if setupErr != nil {