diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index abb9600ce..83c90021d 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -64,6 +64,7 @@ func Start(ctx context.Context, sqlClient *sql.DB, es *eventstore.Eventstore, co NewProjectMemberProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_members"])) NewProjectGrantMemberProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["project_grant_members"])) NewAuthNKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["authn_keys"])) + NewUserGrantProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_grants"])) _, err := NewKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["keys"]), defaults.KeyConfig, keyChan) return err diff --git a/internal/query/projection/user_grant.go b/internal/query/projection/user_grant.go new file mode 100644 index 000000000..2ced2c50c --- /dev/null +++ b/internal/query/projection/user_grant.go @@ -0,0 +1,255 @@ +package projection + +import ( + "context" + + "github.com/caos/logging" + "github.com/lib/pq" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/handler/crdb" + "github.com/caos/zitadel/internal/repository/project" + "github.com/caos/zitadel/internal/repository/user" + "github.com/caos/zitadel/internal/repository/usergrant" +) + +type UserGrantProjection struct { + crdb.StatementHandler +} + +const ( + UserGrantProjectionTable = "zitadel.projections.user_grants" +) + +func NewUserGrantProjection(ctx context.Context, config crdb.StatementHandlerConfig) *UserGrantProjection { + p := &UserGrantProjection{} + config.ProjectionName = UserGrantProjectionTable + config.Reducers = p.reducers() + p.StatementHandler = crdb.NewStatementHandler(ctx, config) + return p +} + +func (p *UserGrantProjection) reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: usergrant.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: usergrant.UserGrantAddedType, + Reduce: p.reduceAdded, + }, + { + Event: usergrant.UserGrantChangedType, + Reduce: p.reduceChanged, + }, + { + Event: usergrant.UserGrantCascadeChangedType, + Reduce: p.reduceChanged, + }, + { + Event: usergrant.UserGrantRemovedType, + Reduce: p.reduceRemoved, + }, + { + Event: usergrant.UserGrantCascadeRemovedType, + Reduce: p.reduceRemoved, + }, + { + Event: usergrant.UserGrantDeactivatedType, + Reduce: p.reduceDeactivated, + }, + { + Event: usergrant.UserGrantReactivatedType, + Reduce: p.reduceReactivated, + }, + }, + }, + { + Aggregate: user.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: user.UserRemovedType, + Reduce: p.reduceUserRemoved, + }, + }, + }, + { + Aggregate: project.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: project.ProjectRemovedType, + Reduce: p.reduceProjectRemoved, + }, + { + Event: project.GrantRemovedType, + Reduce: p.reduceProjectGrantRemoved, + }, + }, + }, + } +} + +type UserGrantColumn string + +const ( + UserGrantID = "id" + UserGrantResourceOwner = "resource_owner" + UserGrantCreationDate = "creation_date" + UserGrantChangeDate = "change_date" + UserGrantSequence = "sequence" + UserGrantUserID = "user_id" + UserGrantProjectID = "project_id" + UserGrantGrantID = "grant_id" + UserGrantRoles = "roles" + UserGrantState = "state" +) + +func (p *UserGrantProjection) reduceAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*usergrant.UserGrantAddedEvent) + if !ok { + logging.LogWithFields("PROJE-WYOHD", "seq", event.Sequence(), "expectedType", usergrant.UserGrantAddedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-MQHVB", "reduce.wrong.event.type") + } + return crdb.NewCreateStatement( + e, + []handler.Column{ + handler.NewCol(UserGrantID, e.Aggregate().ID), + handler.NewCol(UserGrantResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(UserGrantCreationDate, e.CreationDate()), + handler.NewCol(UserGrantChangeDate, e.CreationDate()), + handler.NewCol(UserGrantSequence, e.Sequence()), + handler.NewCol(UserGrantUserID, e.UserID), + handler.NewCol(UserGrantProjectID, e.ProjectID), + handler.NewCol(UserGrantGrantID, e.ProjectGrantID), + handler.NewCol(UserGrantRoles, pq.StringArray(e.RoleKeys)), + handler.NewCol(UserGrantState, domain.UserGrantStateActive), + }, + ), nil +} + +func (p *UserGrantProjection) reduceChanged(event eventstore.Event) (*handler.Statement, error) { + var roles pq.StringArray + + switch e := event.(type) { + case *usergrant.UserGrantChangedEvent: + roles = e.RoleKeys + case *usergrant.UserGrantCascadeChangedEvent: + roles = e.RoleKeys + default: + logging.LogWithFields("PROJE-dIflx", "seq", event.Sequence(), "expectedTypes", []eventstore.EventType{usergrant.UserGrantChangedType, usergrant.UserGrantCascadeChangedType}).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-hOr1E", "reduce.wrong.event.type") + } + + return crdb.NewUpdateStatement( + event, + []handler.Column{ + handler.NewCol(UserGrantChangeDate, event.CreationDate()), + handler.NewCol(UserGrantRoles, roles), + handler.NewCol(UserGrantSequence, event.Sequence()), + }, + []handler.Condition{ + handler.NewCond(UserGrantID, event.Aggregate().ID), + }, + ), nil +} + +func (p *UserGrantProjection) reduceRemoved(event eventstore.Event) (*handler.Statement, error) { + switch event.(type) { + case *usergrant.UserGrantRemovedEvent, *usergrant.UserGrantCascadeRemovedEvent: + // ok + default: + logging.LogWithFields("PROJE-Nw0cR", "seq", event.Sequence(), "expectedTypes", []eventstore.EventType{usergrant.UserGrantRemovedType, usergrant.UserGrantCascadeRemovedType}).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-7OBEC", "reduce.wrong.event.type") + } + + return crdb.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(UserGrantID, event.Aggregate().ID), + }, + ), nil +} + +func (p *UserGrantProjection) reduceDeactivated(event eventstore.Event) (*handler.Statement, error) { + if _, ok := event.(*usergrant.UserGrantDeactivatedEvent); !ok { + logging.LogWithFields("PROJE-V3txf", "seq", event.Sequence(), "expectedType", usergrant.UserGrantDeactivatedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-oP7Gm", "reduce.wrong.event.type") + } + + return crdb.NewUpdateStatement( + event, + []handler.Column{ + handler.NewCol(UserGrantChangeDate, event.CreationDate()), + handler.NewCol(UserGrantState, domain.UserGrantStateInactive), + handler.NewCol(UserGrantSequence, event.Sequence()), + }, + []handler.Condition{ + handler.NewCond(UserGrantID, event.Aggregate().ID), + }, + ), nil +} + +func (p *UserGrantProjection) reduceReactivated(event eventstore.Event) (*handler.Statement, error) { + if _, ok := event.(*usergrant.UserGrantDeactivatedEvent); !ok { + logging.LogWithFields("PROJE-ly6oe", "seq", event.Sequence(), "expectedType", usergrant.UserGrantReactivatedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-DGsKh", "reduce.wrong.event.type") + } + + return crdb.NewUpdateStatement( + event, + []handler.Column{ + handler.NewCol(UserGrantChangeDate, event.CreationDate()), + handler.NewCol(UserGrantState, domain.UserGrantStateActive), + handler.NewCol(UserGrantSequence, event.Sequence()), + }, + []handler.Condition{ + handler.NewCond(UserGrantID, event.Aggregate().ID), + }, + ), nil +} + +func (p *UserGrantProjection) reduceUserRemoved(event eventstore.Event) (*handler.Statement, error) { + if _, ok := event.(*user.UserRemovedEvent); !ok { + logging.LogWithFields("PROJE-Vfeg3", "seq", event.Sequence(), "expectedType", user.UserRemovedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-Bner2a", "reduce.wrong.event.type") + } + + return crdb.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(UserGrantUserID, event.Aggregate().ID), + }, + ), nil +} + +func (p *UserGrantProjection) reduceProjectRemoved(event eventstore.Event) (*handler.Statement, error) { + if _, ok := event.(*project.ProjectRemovedEvent); !ok { + logging.LogWithFields("PROJE-Vfeg3", "seq", event.Sequence(), "expectedType", project.ProjectRemovedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-Bne2a", "reduce.wrong.event.type") + } + + return crdb.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(UserGrantProjectID, event.Aggregate().ID), + }, + ), nil +} + +func (p *UserGrantProjection) reduceProjectGrantRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*project.GrantRemovedEvent) + if !ok { + logging.LogWithFields("PROJE-DGfe2", "seq", event.Sequence(), "expectedType", project.GrantRemovedType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "PROJE-dGr2a", "reduce.wrong.event.type") + } + + return crdb.NewDeleteStatement( + event, + []handler.Condition{ + handler.NewCond(UserGrantGrantID, e.GrantID), + }, + ), nil +} diff --git a/internal/query/projection/user_grant_test.go b/internal/query/projection/user_grant_test.go new file mode 100644 index 000000000..d92b57ab2 --- /dev/null +++ b/internal/query/projection/user_grant_test.go @@ -0,0 +1,341 @@ +package projection + +import ( + "testing" + + "github.com/lib/pq" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/repository/project" + "github.com/caos/zitadel/internal/repository/user" + "github.com/caos/zitadel/internal/repository/usergrant" +) + +func TestUserGrantProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "reduceAdded", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantAddedType), + usergrant.AggregateType, + []byte(`{ + "userId": "user-id", + "projectId": "project-id", + "roleKeys": ["role"] + }`), + ), usergrant.UserGrantAddedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceAdded, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO zitadel.projections.user_grants (id, resource_owner, creation_date, change_date, sequence, user_id, project_id, grant_id, roles, state) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + expectedArgs: []interface{}{ + "agg-id", + "ro-id", + anyArg{}, + anyArg{}, + uint64(15), + "user-id", + "project-id", + "", + pq.StringArray{"role"}, + domain.UserGrantStateActive, + }, + }, + }, + }, + }, + }, + { + name: "reduceChanged", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantChangedType), + usergrant.AggregateType, + []byte(`{ + "roleKeys": ["role"] + }`), + ), usergrant.UserGrantChangedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceChanged, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_grants SET (change_date, roles, sequence) = ($1, $2, $3) WHERE (id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + pq.StringArray{"role"}, + uint64(15), + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceCascadeChanged", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantCascadeChangedType), + usergrant.AggregateType, + []byte(`{ + "roleKeys": ["role"] + }`), + ), usergrant.UserGrantCascadeChangedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceChanged, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_grants SET (change_date, roles, sequence) = ($1, $2, $3) WHERE (id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + pq.StringArray{"role"}, + uint64(15), + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantRemovedType), + usergrant.AggregateType, + nil, + ), usergrant.UserGrantRemovedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceRemoved, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.user_grants WHERE (id = $1)", + expectedArgs: []interface{}{ + anyArg{}, + }, + }, + }, + }, + }, + }, + { + name: "reduceCascadeRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantCascadeRemovedType), + usergrant.AggregateType, + nil, + ), usergrant.UserGrantCascadeRemovedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceRemoved, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.user_grants WHERE (id = $1)", + expectedArgs: []interface{}{ + anyArg{}, + }, + }, + }, + }, + }, + }, + { + name: "reduceDeactivated", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantDeactivatedType), + usergrant.AggregateType, + nil, + ), usergrant.UserGrantDeactivatedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceDeactivated, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_grants SET (change_date, state, sequence) = ($1, $2, $3) WHERE (id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + domain.UserGrantStateInactive, + uint64(15), + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceReactivated", + args: args{ + event: getEvent(testEvent( + repository.EventType(usergrant.UserGrantReactivatedType), + usergrant.AggregateType, + nil, + ), usergrant.UserGrantDeactivatedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceReactivated, + want: wantReduce{ + aggregateType: usergrant.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.user_grants SET (change_date, state, sequence) = ($1, $2, $3) WHERE (id = $4)", + expectedArgs: []interface{}{ + anyArg{}, + domain.UserGrantStateActive, + uint64(15), + "agg-id", + }, + }, + }, + }, + }, + }, + { + name: "reduceUserRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(user.UserRemovedType), + user.AggregateType, + nil, + ), user.UserRemovedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceUserRemoved, + want: wantReduce{ + aggregateType: user.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.user_grants WHERE (user_id = $1)", + expectedArgs: []interface{}{ + anyArg{}, + }, + }, + }, + }, + }, + }, + { + name: "reduceProjectRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(project.ProjectRemovedType), + project.AggregateType, + nil, + ), project.ProjectRemovedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceProjectRemoved, + want: wantReduce{ + aggregateType: project.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.user_grants WHERE (project_id = $1)", + expectedArgs: []interface{}{ + anyArg{}, + }, + }, + }, + }, + }, + }, + { + name: "reduceProjectGrantRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(project.GrantRemovedType), + project.AggregateType, + []byte(`{"grantId": "grantID"}`), + ), project.GrantRemovedEventMapper), + }, + reduce: (&UserGrantProjection{}).reduceProjectGrantRemoved, + want: wantReduce{ + aggregateType: project.AggregateType, + sequence: 15, + previousSequence: 10, + projection: UserGrantProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.user_grants WHERE (grant_id = $1)", + expectedArgs: []interface{}{ + "grantID", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if _, ok := err.(errors.InvalidArgument); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, tt.want) + }) + } +} diff --git a/internal/query/query.go b/internal/query/query.go index 2f5685a21..04531ed0a 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -18,6 +18,7 @@ import ( "github.com/caos/zitadel/internal/repository/org" "github.com/caos/zitadel/internal/repository/project" usr_repo "github.com/caos/zitadel/internal/repository/user" + "github.com/caos/zitadel/internal/repository/usergrant" "github.com/caos/zitadel/internal/telemetry/tracing" "github.com/rakyll/statik/fs" "golang.org/x/text/language" @@ -68,6 +69,7 @@ func StartQueries(ctx context.Context, es *eventstore.Eventstore, projections pr project.RegisterEventMappers(repo.eventstore) action.RegisterEventMappers(repo.eventstore) keypair.RegisterEventMappers(repo.eventstore) + usergrant.RegisterEventMappers(repo.eventstore) err = projection.Start(ctx, sqlClient, es, projections, defaults, keyChan) if err != nil { diff --git a/migrations/cockroach/V1.104__user_grants.sql b/migrations/cockroach/V1.104__user_grants.sql new file mode 100644 index 000000000..00902f90f --- /dev/null +++ b/migrations/cockroach/V1.104__user_grants.sql @@ -0,0 +1,17 @@ +CREATE TABLE zitadel.projections.user_grants ( + id STRING NOT NULL + , resource_owner STRING NOT NULL + , creation_date TIMESTAMPTZ NOT NULL + , change_date TIMESTAMPTZ NOT NULL + , sequence INT8 NOT NULL + + , user_id STRING NOT NULL + , project_id STRING NOT NULL + , grant_id STRING NOT NULL + , roles STRING[] + , state INT2 NOT NULL + + , PRIMARY KEY (id) + , INDEX idx_user (user_id) + , INDEX idx_ro (resource_owner) +); \ No newline at end of file