zitadel/internal/command/system_features_model.go
Tim Möhlmann 3f6ea78c87
perf: role permissions in database (#9152)
# Which Problems Are Solved

Currently ZITADEL defines organization and instance member roles and
permissions in defaults.yaml. The permission check is done on API call
level. For example: "is this user allowed to make this call on this
org". This makes sense on the V1 API where the API is permission-level
shaped. For example, a search for users always happens in the context of
the organization. (Either the organization the calling user belongs to,
or through member ship and the x-zitadel-orgid header.

However, for resource based APIs we must be able to resolve permissions
by object. For example, an IAM_OWNER listing users should be able to get
all users in an instance based on the query filters. Alternatively a
user may have user.read permissions on one or more orgs. They should be
able to read just those users.

# How the Problems Are Solved

## Role permission mapping

The role permission mappings defined from `defaults.yaml` or local
config override are synchronized to the database on every run of
`zitadel setup`:

- A single query per **aggregate** builds a list of `add` and `remove`
actions needed to reach the desired state or role permission mappings
from the config.
- The required events based on the actions are pushed to the event
store.
- Events define search fields so that permission checking can use the
indices and is strongly consistent for both query and command sides.

The migration is split in the following aggregates:

- System aggregate for for roles prefixed with `SYSTEM`
- Each instance for roles not prefixed with `SYSTEM`. This is in
anticipation of instance level management over the API.

## Membership

Current instance / org / project membership events now have field table
definitions. Like the role permissions this ensures strong consistency
while still being able to use the indices of the fields table. A
migration is provided to fill the membership fields.

## Permission check

I aimed keeping the mental overhead to the developer to a minimal. The
provided implementation only provides a permission check for list
queries for org level resources, for example users. In the `query`
package there is a simple helper function `wherePermittedOrgs` which
makes sure the underlying database function is called as part of the
`SELECT` query and the permitted organizations are part of the `WHERE`
clause. This makes sure results from non-permitted organizations are
omitted. Under the hood:

- A Pg/PlSQL function searches for a list of organization IDs the passed
user has the passed permission.
- When the user has the permission on instance level, it returns early
with all organizations.
- The functions uses a number of views. The views help mapping the
fields entries into relational data and simplify the code use for the
function. The views provide some pre-filters which allow proper index
usage once the final `WHERE` clauses are set by the function.

# Additional Changes



# Additional Context

Closes #9032
Closes https://github.com/zitadel/zitadel/issues/9014

https://github.com/zitadel/zitadel/issues/9188 defines follow-ups for
the new permission framework based on this concept.
2025-01-16 10:09:15 +00:00

159 lines
6.2 KiB
Go

package command
import (
"context"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/feature"
"github.com/zitadel/zitadel/internal/repository/feature/feature_v2"
)
type SystemFeaturesWriteModel struct {
*eventstore.WriteModel
SystemFeatures
}
func NewSystemFeaturesWriteModel() *SystemFeaturesWriteModel {
m := &SystemFeaturesWriteModel{
WriteModel: &eventstore.WriteModel{
AggregateID: "SYSTEM",
ResourceOwner: "SYSTEM",
},
}
return m
}
func (m *SystemFeaturesWriteModel) Reduce() error {
for _, event := range m.Events {
switch e := event.(type) {
case *feature_v2.ResetEvent:
m.reduceReset()
case *feature_v2.SetEvent[bool]:
_, key, err := e.FeatureInfo()
if err != nil {
return err
}
reduceSystemFeature(&m.SystemFeatures, key, e.Value)
case *feature_v2.SetEvent[*feature.LoginV2]:
_, key, err := e.FeatureInfo()
if err != nil {
return err
}
reduceSystemFeature(&m.SystemFeatures, key, e.Value)
case *feature_v2.SetEvent[[]feature.ImprovedPerformanceType]:
_, key, err := e.FeatureInfo()
if err != nil {
return err
}
reduceSystemFeature(&m.SystemFeatures, key, e.Value)
}
}
return m.WriteModel.Reduce()
}
func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AwaitOpenTransactions().
AddQuery().
AggregateTypes(feature_v2.AggregateType).
AggregateIDs(m.AggregateID).
EventTypes(
feature_v2.SystemResetEventType,
feature_v2.SystemLoginDefaultOrgEventType,
feature_v2.SystemTriggerIntrospectionProjectionsEventType,
feature_v2.SystemLegacyIntrospectionEventType,
feature_v2.SystemUserSchemaEventType,
feature_v2.SystemTokenExchangeEventType,
feature_v2.SystemActionsEventType,
feature_v2.SystemImprovedPerformanceEventType,
feature_v2.SystemOIDCSingleV1SessionTerminationEventType,
feature_v2.SystemDisableUserTokenEvent,
feature_v2.SystemEnableBackChannelLogout,
feature_v2.SystemLoginVersion,
feature_v2.SystemPermissionCheckV2,
).
Builder().ResourceOwner(m.ResourceOwner)
}
func (m *SystemFeaturesWriteModel) reduceReset() {
m.SystemFeatures = SystemFeatures{}
}
func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) {
switch key {
case feature.KeyUnspecified:
return
case feature.KeyLoginDefaultOrg:
v := value.(bool)
features.LoginDefaultOrg = &v
case feature.KeyTriggerIntrospectionProjections:
v := value.(bool)
features.TriggerIntrospectionProjections = &v
case feature.KeyLegacyIntrospection:
v := value.(bool)
features.LegacyIntrospection = &v
case feature.KeyUserSchema:
v := value.(bool)
features.UserSchema = &v
case feature.KeyTokenExchange:
v := value.(bool)
features.TokenExchange = &v
case feature.KeyActions:
v := value.(bool)
features.Actions = &v
case feature.KeyImprovedPerformance:
features.ImprovedPerformance = value.([]feature.ImprovedPerformanceType)
case feature.KeyOIDCSingleV1SessionTermination:
v := value.(bool)
features.OIDCSingleV1SessionTermination = &v
case feature.KeyDisableUserTokenEvent:
v := value.(bool)
features.DisableUserTokenEvent = &v
case feature.KeyEnableBackChannelLogout:
v := value.(bool)
features.EnableBackChannelLogout = &v
case feature.KeyLoginV2:
features.LoginV2 = value.(*feature.LoginV2)
case feature.KeyPermissionCheckV2:
v := value.(bool)
features.PermissionCheckV2 = &v
}
}
func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFeatures) []eventstore.Command {
aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner)
cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.Actions, f.Actions, feature_v2.SystemActionsEventType)
cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.SystemOIDCSingleV1SessionTerminationEventType)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.EnableBackChannelLogout, f.EnableBackChannelLogout, feature_v2.SystemEnableBackChannelLogout)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginV2, f.LoginV2, feature_v2.SystemLoginVersion)
cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.PermissionCheckV2, f.PermissionCheckV2, feature_v2.SystemPermissionCheckV2)
return cmds
}
func appendFeatureUpdate[T comparable](ctx context.Context, cmds []eventstore.Command, aggregate *feature_v2.Aggregate, oldValue, newValue *T, eventType eventstore.EventType) []eventstore.Command {
if newValue != nil && (oldValue == nil || *oldValue != *newValue) {
cmds = append(cmds, feature_v2.NewSetEvent[T](ctx, aggregate, eventType, *newValue))
}
return cmds
}
func appendFeatureSliceUpdate[T comparable](ctx context.Context, cmds []eventstore.Command, aggregate *feature_v2.Aggregate, oldValues, newValues []T, eventType eventstore.EventType) []eventstore.Command {
if len(newValues) != len(oldValues) {
return append(cmds, feature_v2.NewSetEvent[[]T](ctx, aggregate, eventType, newValues))
}
for i, oldValue := range oldValues {
if oldValue != newValues[i] {
return append(cmds, feature_v2.NewSetEvent[[]T](ctx, aggregate, eventType, newValues))
}
}
return cmds
}