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

280 lines
6.6 KiB
Go

package member
import (
"fmt"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/zerrors"
)
// Event types
const (
UniqueMember = "member"
AddedEventType = "member.added"
ChangedEventType = "member.changed"
RemovedEventType = "member.removed"
CascadeRemovedEventType = "member.cascade.removed"
)
// Field table and unique types
const (
memberRoleTypeSuffix string = "_member_role"
MemberRoleRevision uint8 = 1
roleSearchFieldSuffix string = "_role"
)
func NewAddMemberUniqueConstraint(aggregateID, userID string) *eventstore.UniqueConstraint {
return eventstore.NewAddEventUniqueConstraint(
UniqueMember,
fmt.Sprintf("%s:%s", aggregateID, userID),
"Errors.Member.AlreadyExists")
}
func NewRemoveMemberUniqueConstraint(aggregateID, userID string) *eventstore.UniqueConstraint {
return eventstore.NewRemoveUniqueConstraint(
UniqueMember,
fmt.Sprintf("%s:%s", aggregateID, userID),
)
}
type MemberAddedEvent struct {
eventstore.BaseEvent `json:"-"`
Roles []string `json:"roles"`
UserID string `json:"userId"`
}
func (e *MemberAddedEvent) Payload() interface{} {
return e
}
func (e *MemberAddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return []*eventstore.UniqueConstraint{NewAddMemberUniqueConstraint(e.Aggregate().ID, e.UserID)}
}
func (e *MemberAddedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation {
ops := make([]*eventstore.FieldOperation, len(e.Roles))
for i, role := range e.Roles {
ops[i] = eventstore.SetField(
e.Aggregate(),
memberSearchObject(prefix, e.UserID),
prefix+roleSearchFieldSuffix,
&eventstore.Value{
Value: role,
MustBeUnique: false,
ShouldIndex: true,
},
eventstore.FieldTypeInstanceID,
eventstore.FieldTypeResourceOwner,
eventstore.FieldTypeAggregateType,
eventstore.FieldTypeAggregateID,
eventstore.FieldTypeObjectType,
eventstore.FieldTypeObjectID,
eventstore.FieldTypeFieldName,
eventstore.FieldTypeValue,
)
}
return ops
}
func NewMemberAddedEvent(
base *eventstore.BaseEvent,
userID string,
roles ...string,
) *MemberAddedEvent {
return &MemberAddedEvent{
BaseEvent: *base,
Roles: roles,
UserID: userID,
}
}
func MemberAddedEventMapper(event eventstore.Event) (eventstore.Event, error) {
e := &MemberAddedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(e)
if err != nil {
return nil, zerrors.ThrowInternal(err, "POLIC-puqv4", "unable to unmarshal label policy")
}
return e, nil
}
type MemberChangedEvent struct {
eventstore.BaseEvent `json:"-"`
Roles []string `json:"roles,omitempty"`
UserID string `json:"userId,omitempty"`
}
func (e *MemberChangedEvent) Payload() interface{} {
return e
}
func (e *MemberChangedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return nil
}
// FieldOperations removes the existing membership role fields first and sets the new roles after.
func (e *MemberChangedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation {
ops := make([]*eventstore.FieldOperation, len(e.Roles)+1)
ops[0] = eventstore.RemoveSearchFieldsByAggregateAndObject(
e.Aggregate(),
memberSearchObject(prefix, e.UserID),
)
for i, role := range e.Roles {
ops[i+1] = eventstore.SetField(
e.Aggregate(),
memberSearchObject(prefix, e.UserID),
prefix+roleSearchFieldSuffix,
&eventstore.Value{
Value: role,
MustBeUnique: false,
ShouldIndex: true,
},
eventstore.FieldTypeInstanceID,
eventstore.FieldTypeResourceOwner,
eventstore.FieldTypeAggregateType,
eventstore.FieldTypeAggregateID,
eventstore.FieldTypeObjectType,
eventstore.FieldTypeObjectID,
eventstore.FieldTypeFieldName,
eventstore.FieldTypeValue,
)
}
return ops
}
func NewMemberChangedEvent(
base *eventstore.BaseEvent,
userID string,
roles ...string,
) *MemberChangedEvent {
return &MemberChangedEvent{
BaseEvent: *base,
Roles: roles,
UserID: userID,
}
}
func ChangedEventMapper(event eventstore.Event) (eventstore.Event, error) {
e := &MemberChangedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(e)
if err != nil {
return nil, zerrors.ThrowInternal(err, "POLIC-puqv4", "unable to unmarshal label policy")
}
return e, nil
}
type MemberRemovedEvent struct {
eventstore.BaseEvent `json:"-"`
UserID string `json:"userId"`
}
func (e *MemberRemovedEvent) Payload() interface{} {
return e
}
func (e *MemberRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return []*eventstore.UniqueConstraint{NewRemoveMemberUniqueConstraint(e.Aggregate().ID, e.UserID)}
}
func (e *MemberRemovedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation {
return []*eventstore.FieldOperation{
eventstore.RemoveSearchFieldsByAggregateAndObject(
e.Aggregate(),
memberSearchObject(prefix, e.UserID),
),
}
}
func NewRemovedEvent(
base *eventstore.BaseEvent,
userID string,
) *MemberRemovedEvent {
return &MemberRemovedEvent{
BaseEvent: *base,
UserID: userID,
}
}
func RemovedEventMapper(event eventstore.Event) (eventstore.Event, error) {
e := &MemberRemovedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(e)
if err != nil {
return nil, zerrors.ThrowInternal(err, "MEMBER-Ep4ip", "unable to unmarshal label policy")
}
return e, nil
}
type MemberCascadeRemovedEvent struct {
eventstore.BaseEvent `json:"-"`
UserID string `json:"userId"`
}
func (e *MemberCascadeRemovedEvent) Payload() interface{} {
return e
}
func (e *MemberCascadeRemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
return []*eventstore.UniqueConstraint{NewRemoveMemberUniqueConstraint(e.Aggregate().ID, e.UserID)}
}
func (e *MemberCascadeRemovedEvent) FieldOperations(prefix string) []*eventstore.FieldOperation {
return []*eventstore.FieldOperation{
eventstore.RemoveSearchFieldsByAggregateAndObject(
e.Aggregate(),
memberSearchObject(prefix, e.UserID),
),
}
}
func NewCascadeRemovedEvent(
base *eventstore.BaseEvent,
userID string,
) *MemberCascadeRemovedEvent {
return &MemberCascadeRemovedEvent{
BaseEvent: *base,
UserID: userID,
}
}
func CascadeRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) {
e := &MemberCascadeRemovedEvent{
BaseEvent: *eventstore.BaseEventFromRepo(event),
}
err := event.Unmarshal(e)
if err != nil {
return nil, zerrors.ThrowInternal(err, "MEMBER-3j9sf", "unable to unmarshal label policy")
}
return e, nil
}
func memberSearchObject(prefix, userID string) eventstore.Object {
return eventstore.Object{
Type: prefix + memberRoleTypeSuffix,
ID: userID,
Revision: MemberRoleRevision,
}
}