zitadel/internal/query/instance_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

148 lines
4.9 KiB
Go

package query
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/feature"
feature_v1 "github.com/zitadel/zitadel/internal/repository/feature"
"github.com/zitadel/zitadel/internal/repository/feature/feature_v2"
)
type InstanceFeaturesReadModel struct {
*eventstore.ReadModel
system *SystemFeatures
instance *InstanceFeatures
}
func NewInstanceFeaturesReadModel(ctx context.Context, system *SystemFeatures) *InstanceFeaturesReadModel {
instanceID := authz.GetInstance(ctx).InstanceID()
m := &InstanceFeaturesReadModel{
ReadModel: &eventstore.ReadModel{
AggregateID: instanceID,
ResourceOwner: instanceID,
},
instance: new(InstanceFeatures),
system: system,
}
m.populateFromSystem()
return m
}
func (m *InstanceFeaturesReadModel) Reduce() (err error) {
for _, event := range m.Events {
switch e := event.(type) {
case *feature_v2.ResetEvent:
m.reduceReset()
case *feature_v1.SetEvent[feature_v1.Boolean]:
err = reduceInstanceFeatureSet(
m.instance,
feature_v1.DefaultLoginInstanceEventToV2(e),
)
case *feature_v2.SetEvent[bool]:
err = reduceInstanceFeatureSet(m.instance, e)
case *feature_v2.SetEvent[*feature.LoginV2]:
err = reduceInstanceFeatureSet(m.instance, e)
case *feature_v2.SetEvent[[]feature.ImprovedPerformanceType]:
err = reduceInstanceFeatureSet(m.instance, e)
}
if err != nil {
return err
}
}
return m.ReadModel.Reduce()
}
func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
AwaitOpenTransactions().
AddQuery().
AggregateTypes(feature_v2.AggregateType).
EventTypes(
feature_v1.DefaultLoginInstanceEventType,
feature_v2.InstanceResetEventType,
feature_v2.InstanceLoginDefaultOrgEventType,
feature_v2.InstanceTriggerIntrospectionProjectionsEventType,
feature_v2.InstanceLegacyIntrospectionEventType,
feature_v2.InstanceUserSchemaEventType,
feature_v2.InstanceTokenExchangeEventType,
feature_v2.InstanceActionsEventType,
feature_v2.InstanceImprovedPerformanceEventType,
feature_v2.InstanceWebKeyEventType,
feature_v2.InstanceDebugOIDCParentErrorEventType,
feature_v2.InstanceOIDCSingleV1SessionTerminationEventType,
feature_v2.InstanceDisableUserTokenEvent,
feature_v2.InstanceEnableBackChannelLogout,
feature_v2.InstanceLoginVersion,
feature_v2.InstancePermissionCheckV2,
).
Builder().ResourceOwner(m.ResourceOwner)
}
func (m *InstanceFeaturesReadModel) reduceReset() {
if m.populateFromSystem() {
return
}
m.instance = nil
m.instance = new(InstanceFeatures)
}
func (m *InstanceFeaturesReadModel) populateFromSystem() bool {
if m.system == nil {
return false
}
m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg
m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections
m.instance.LegacyIntrospection = m.system.LegacyIntrospection
m.instance.UserSchema = m.system.UserSchema
m.instance.TokenExchange = m.system.TokenExchange
m.instance.Actions = m.system.Actions
m.instance.ImprovedPerformance = m.system.ImprovedPerformance
m.instance.OIDCSingleV1SessionTermination = m.system.OIDCSingleV1SessionTermination
m.instance.DisableUserTokenEvent = m.system.DisableUserTokenEvent
m.instance.EnableBackChannelLogout = m.system.EnableBackChannelLogout
m.instance.LoginV2 = m.system.LoginV2
return true
}
func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_v2.SetEvent[T]) error {
level, key, err := event.FeatureInfo()
if err != nil {
return err
}
switch key {
case feature.KeyUnspecified:
return nil
case feature.KeyLoginDefaultOrg:
features.LoginDefaultOrg.set(level, event.Value)
case feature.KeyTriggerIntrospectionProjections:
features.TriggerIntrospectionProjections.set(level, event.Value)
case feature.KeyLegacyIntrospection:
features.LegacyIntrospection.set(level, event.Value)
case feature.KeyUserSchema:
features.UserSchema.set(level, event.Value)
case feature.KeyTokenExchange:
features.TokenExchange.set(level, event.Value)
case feature.KeyActions:
features.Actions.set(level, event.Value)
case feature.KeyImprovedPerformance:
features.ImprovedPerformance.set(level, event.Value)
case feature.KeyWebKey:
features.WebKey.set(level, event.Value)
case feature.KeyDebugOIDCParentError:
features.DebugOIDCParentError.set(level, event.Value)
case feature.KeyOIDCSingleV1SessionTermination:
features.OIDCSingleV1SessionTermination.set(level, event.Value)
case feature.KeyDisableUserTokenEvent:
features.DisableUserTokenEvent.set(level, event.Value)
case feature.KeyEnableBackChannelLogout:
features.EnableBackChannelLogout.set(level, event.Value)
case feature.KeyLoginV2:
features.LoginV2.set(level, event.Value)
case feature.KeyPermissionCheckV2:
features.PermissionCheckV2.set(level, event.Value)
}
return nil
}