diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 30e037c80f..8482ccec9f 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -607,6 +607,16 @@ EncryptionKeys: UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID SystemAPIUsers: + - superuser: + Path: /path/to/superuser/key.pem + Memberships: + - MemberType: Organization + Roles: "ORG_OWNER" + AggregateID: "123456789012345678" + - MemberType: Project + Roles: "PROJECT_OWNER" + + # # Add keys for authentication of the systemAPI here: # # you can specify any name for the user, but they will have to match the `issuer` and `sub` claim in the JWT: # - superuser: diff --git a/cmd/setup/53.go b/cmd/setup/53.go index 83a7b1c0e2..952fc37916 100644 --- a/cmd/setup/53.go +++ b/cmd/setup/53.go @@ -33,5 +33,5 @@ func (mig *InitPermittedOrgsFunction53) Execute(ctx context.Context, _ eventstor } func (*InitPermittedOrgsFunction53) String() string { - return "53_init_permitted_orgs_function" + return "53_init_permitted_orgs_function_v2" } diff --git a/cmd/setup/53/01-get-permissions-from-JSON.sql b/cmd/setup/53/01-get-permissions-from-JSON.sql index b6415fa180..531184dbe4 100644 --- a/cmd/setup/53/01-get-permissions-from-JSON.sql +++ b/cmd/setup/53/01-get-permissions-from-JSON.sql @@ -1,23 +1,28 @@ +DROP FUNCTION IF EXISTS eventstore.check_system_user_perms; DROP FUNCTION IF EXISTS eventstore.get_system_permissions; +DROP TYPE IF EXISTS eventstore.project_grant; + +/* + Function get_system_permissions unpacks an JSON array of system member permissions, + into a table format. Each array entry maps to one row representing a membership which + contained the req_permission. + + [ + { + "member_type": "IAM", + "aggregate_id": "310716990375453665", + "object_id": "", + "permissions": ["iam.read", "iam.write", "iam.policy.read"] + }, + ... + ] + + | member_type | aggregate_id | object_id | + | "IAM" | "310716990375453665" | null | +*/ CREATE OR REPLACE FUNCTION eventstore.get_system_permissions( permissions_json JSONB - /* - [ - { - "member_type": "System", - "aggregate_id": "", - "object_id": "", - "permissions": ["iam.read", "iam.write", "iam.polic.read"] - }, - { - "member_type": "IAM", - "aggregate_id": "310716990375453665", - "object_id": "", - "permissions": ["iam.read", "iam.write", "iam.polic.read"] - } - ] - */ , permm TEXT ) RETURNS TABLE ( @@ -25,7 +30,7 @@ RETURNS TABLE ( aggregate_id TEXT, object_id TEXT ) - LANGUAGE 'plpgsql' + LANGUAGE 'plpgsql' IMMUTABLE AS $$ BEGIN RETURN QUERY @@ -37,7 +42,73 @@ BEGIN permission FROM jsonb_array_elements(permissions_json) AS perm CROSS JOIN jsonb_array_elements_text(perm->'permissions') AS permission) AS res - WHERE res. permission= permm; + WHERE res.permission = permm; END; $$; +/* + Type project_grant is composite identifier using its project and grant IDs. +*/ +CREATE TYPE eventstore.project_grant AS ( + project_id TEXT -- mapped from a permission's aggregate_id + , grant_id TEXT -- mapped from a permission's object_id +); + +/* + Function check_system_user_perms uses system member permissions to establish + on which organization, project or project grant the user has the requested permission. + The permission can also apply to the complete instance when a IAM membership matches + the requested instance ID, or through system membership. + + See eventstore.get_system_permissions() on the supported JSON format. +*/ +CREATE OR REPLACE FUNCTION eventstore.check_system_user_perms( + system_user_perms JSONB + , req_instance_id TEXT + , perm TEXT + + , instance_permitted OUT BOOLEAN + , org_ids OUT TEXT[] + , project_ids OUT TEXT[] + , project_grants OUT eventstore.project_grant[] +) + LANGUAGE 'plpgsql' IMMUTABLE +AS $$ +BEGIN + -- make sure no nulls are returned + instance_permitted := FALSE; + org_ids := ARRAY[]::TEXT[]; + project_ids := ARRAY[]::TEXT[]; + project_grants := ARRAY[]::eventstore.project_grant[]; + DECLARE + p RECORD; + BEGIN + FOR p IN SELECT member_type, aggregate_id, object_id + FROM eventstore.get_system_permissions(system_user_perms, perm) + LOOP + CASE p.member_type + WHEN 'System' THEN + instance_permitted := TRUE; + RETURN; + WHEN 'IAM' THEN + IF p.aggregate_id = req_instance_id THEN + instance_permitted := TRUE; + RETURN; + END IF; + WHEN 'Organization' THEN + IF p.aggregate_id != '' THEN + org_ids := array_append(org_ids, p.aggregate_id); + END IF; + WHEN 'Project' THEN + IF p.aggregate_id != '' THEN + project_ids := array_append(project_ids, p.aggregate_id); + END IF; + WHEN 'ProjectGrant' THEN + IF p.aggregate_id != '' THEN + project_grants := array_append(project_grants, ROW(p.aggregate_id, p.object_id)::eventstore.project_grant); + END IF; + END CASE; + END LOOP; + END; +END; +$$; diff --git a/cmd/setup/53/02-permitted_orgs_function.sql b/cmd/setup/53/02-permitted_orgs_function.sql index b6f61c6225..fbc7eaee59 100644 --- a/cmd/setup/53/02-permitted_orgs_function.sql +++ b/cmd/setup/53/02-permitted_orgs_function.sql @@ -1,144 +1,71 @@ -DROP FUNCTION IF EXISTS eventstore.check_system_user_perms; +DROP FUNCTION IF EXISTS eventstore.permitted_orgs; +DROP FUNCTION IF EXISTS eventstore.find_roles; -CREATE OR REPLACE FUNCTION eventstore.check_system_user_perms( - system_user_perms JSONB +-- find_roles finds all roles containing the permission +CREATE OR REPLACE FUNCTION eventstore.find_roles( + req_instance_id TEXT , perm TEXT - , filter_orgs TEXT - , org_ids OUT TEXT[] + , roles OUT TEXT[] ) - LANGUAGE 'plpgsql' +LANGUAGE 'plpgsql' STABLE AS $$ BEGIN - - WITH found_permissions(member_type, aggregate_id, object_id ) AS ( - SELECT * FROM eventstore.get_system_permissions( - system_user_perms, - perm) - ) - - SELECT array_agg(DISTINCT o.org_id) INTO org_ids - FROM eventstore.instance_orgs o, found_permissions - WHERE - CASE WHEN (SELECT TRUE WHERE found_permissions.member_type = 'System' LIMIT 1) THEN - TRUE - WHEN (SELECT TRUE WHERE found_permissions.member_type = 'IAM' LIMIT 1) THEN - -- aggregate_id not present - CASE WHEN (SELECT TRUE WHERE '' = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'IAM' - GROUP BY member_type - LIMIT 1 - )::TEXT[])) THEN - TRUE - -- aggregate_id is present - ELSE - o.instance_id = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'IAM' - GROUP BY member_type - LIMIT 1 - )::TEXT[]) - END - WHEN (SELECT TRUE WHERE found_permissions.member_type = 'Organization' LIMIT 1) THEN - -- aggregate_id not present - CASE WHEN (SELECT TRUE WHERE '' = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'Organization' - GROUP BY member_type - LIMIT 1 - )::TEXT[])) THEN - TRUE - -- aggregate_id is present - ELSE - o.org_id = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'Organization' - GROUP BY member_type - LIMIT 1 - )::TEXT[]) - END - END - AND - CASE WHEN filter_orgs != '' - THEN o.org_id IN (filter_orgs) - ELSE TRUE END - LIMIT 1; + SELECT array_agg(rp.role) INTO roles + FROM eventstore.role_permissions rp + WHERE rp.instance_id = req_instance_id + AND rp.permission = perm; END; $$; - -DROP FUNCTION IF EXISTS eventstore.permitted_orgs; - CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( - instanceId TEXT - , userId TEXT + req_instance_id TEXT + , auth_user_id TEXT , system_user_perms JSONB , perm TEXT - , filter_orgs TEXT + , filter_org TEXT + , instance_permitted OUT BOOLEAN , org_ids OUT TEXT[] ) - LANGUAGE 'plpgsql' + LANGUAGE 'plpgsql' STABLE AS $$ BEGIN - - -- if system user - IF system_user_perms IS NOT NULL THEN - org_ids := eventstore.check_system_user_perms(system_user_perms, perm, filter_orgs); - -- if human/machine user - ELSE + -- if system user + IF system_user_perms IS NOT NULL THEN + SELECT p.instance_permitted, p.org_ids INTO instance_permitted, org_ids + FROM eventstore.check_system_user_perms(system_user_perms, req_instance_id, perm) p; + RETURN; + END IF; + + -- if human/machine user DECLARE - matched_roles TEXT[]; -- roles containing permission - BEGIN - - SELECT array_agg(rp.role) INTO matched_roles - FROM eventstore.role_permissions rp - WHERE rp.instance_id = instanceId - AND rp.permission = perm; - - -- First try if the permission was granted thru an instance-level role - DECLARE - has_instance_permission bool; - BEGIN - SELECT true INTO has_instance_permission - FROM eventstore.instance_members im - WHERE im.role = ANY(matched_roles) - AND im.instance_id = instanceId - AND im.user_id = userId - LIMIT 1; + matched_roles TEXT[] := eventstore.find_roles(req_instance_id, perm); + BEGIN + -- First try if the permission was granted thru an instance-level role + SELECT true INTO instance_permitted + FROM eventstore.instance_members im + WHERE im.role = ANY(matched_roles) + AND im.instance_id = req_instance_id + AND im.user_id = auth_user_id + LIMIT 1; - IF has_instance_permission THEN - -- Return all organizations or only those in filter_orgs - SELECT array_agg(o.org_id) INTO org_ids - FROM eventstore.instance_orgs o - WHERE o.instance_id = instanceId - AND CASE WHEN filter_orgs != '' - THEN o.org_id IN (filter_orgs) - ELSE TRUE END; - RETURN; + org_ids := ARRAY[]::TEXT[]; + IF instance_permitted THEN + RETURN; END IF; - END; - - -- Return the organizations where permission were granted thru org-level roles - SELECT array_agg(sub.org_id) INTO org_ids - FROM ( - SELECT DISTINCT om.org_id - FROM eventstore.org_members om - WHERE om.role = ANY(matched_roles) - AND om.instance_id = instanceID - AND om.user_id = userId - ) AS sub; + instance_permitted := FALSE; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(sub.org_id) INTO org_ids + FROM ( + SELECT DISTINCT om.org_id + FROM eventstore.org_members om + WHERE om.role = ANY(matched_roles) + AND om.instance_id = req_instance_id + AND om.user_id = auth_user_id + AND (filter_org IS NULL OR om.org_id = filter_org) + ) AS sub; END; - END IF; END; $$; - diff --git a/cmd/setup/53/03-permitted_projects_func.sql b/cmd/setup/53/03-permitted_projects_func.sql new file mode 100644 index 0000000000..8c17481ce8 --- /dev/null +++ b/cmd/setup/53/03-permitted_projects_func.sql @@ -0,0 +1,58 @@ +-- recreate the view to include the resource_owner +CREATE OR REPLACE VIEW eventstore.project_members AS +SELECT instance_id, aggregate_id as project_id, object_id as user_id, text_value as role, resource_owner as org_id +FROM eventstore.fields +WHERE aggregate_type = 'project' +AND object_type = 'project_member_role' +AND field_name = 'project_role'; + +DROP FUNCTION IF EXISTS eventstore.permitted_projects; + +CREATE OR REPLACE FUNCTION eventstore.permitted_projects( + req_instance_id TEXT + , auth_user_id TEXT + , system_user_perms JSONB + , perm TEXT + , filter_org TEXT + + , instance_permitted OUT BOOLEAN + , org_ids OUT TEXT[] + , project_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' STABLE +AS $$ +BEGIN + -- if system user + IF system_user_perms IS NOT NULL THEN + SELECT p.instance_permitted, p.org_ids INTO instance_permitted, org_ids, project_ids + FROM eventstore.check_system_user_perms(system_user_perms, req_instance_id, perm) p; + RETURN; + END IF; + + -- if human/machine user + SELECT * FROM eventstore.permitted_orgs( + req_instance_id + , auth_user_id + , system_user_perms + , perm + , filter_org + ) INTO instance_permitted, org_ids; + IF instance_permitted THEN + RETURN; + END IF; + DECLARE + matched_roles TEXT[] := eventstore.find_roles(req_instance_id, perm); + BEGIN + -- Get the projects where permission were granted thru project-level roles + SELECT array_agg(sub.project_id) INTO project_ids + FROM ( + SELECT DISTINCT pm.project_id + FROM eventstore.project_members pm + WHERE pm.role = ANY(matched_roles) + AND pm.instance_id = req_instance_id + AND pm.user_id = auth_user_id + AND (filter_org IS NULL OR pm.org_id = filter_org) + ) AS sub; + END; +END; +$$; diff --git a/cmd/setup/integration_test/permission_test.go b/cmd/setup/integration_test/permission_test.go new file mode 100644 index 0000000000..2b0c56865f --- /dev/null +++ b/cmd/setup/integration_test/permission_test.go @@ -0,0 +1,871 @@ +//go:build integration + +package setup_test + +import ( + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/permission" + "github.com/zitadel/zitadel/internal/repository/project" +) + +func TestGetSystemPermissions(t *testing.T) { + const query = "SELECT * FROM eventstore.get_system_permissions($1, $2);" + t.Parallel() + permissions := []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project.read", "project.write"}, + }, + } + type result struct { + MemberType authz.MemberType + AggregateID string + ObjectID string + } + tests := []struct { + permm string + want []result + }{ + { + permm: "iam.read", + want: []result{ + { + MemberType: authz.MemberTypeSystem, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + }, + }, + }, + { + permm: "org.read", + want: []result{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + }, + }, + }, + { + permm: "project.write", + want: []result{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.permm, func(t *testing.T) { + t.Parallel() + rows, err := dbPool.Query(CTX, query, database.NewJSONArray(permissions), tt.permm) + require.NoError(t, err) + got, err := pgx.CollectRows(rows, pgx.RowToStructByPos[result]) + require.NoError(t, err) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestCheckSystemUserPerms(t *testing.T) { + // Use JSON because of the composite project_grants SQL type + const query = "SELECT row_to_json(eventstore.check_system_user_perms($1, $2, $3));" + t.Parallel() + type args struct { + reqInstanceID string + permissions []authz.SystemUserPermissions + permm string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "iam.read, instance permitted from system", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project.read", "project.write"}, + }, + }, + permm: "iam.read", + }, + want: `{ + "instance_permitted": true, + "org_ids": [], + "project_grants": [], + "project_ids": [] + }`, + }, + { + name: "org.read, instance permitted", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project.read", "project.write"}, + }, + }, + permm: "org.read", + }, + want: `{ + "instance_permitted": true, + "org_ids": [], + "project_grants": [], + "project_ids": [] + }`, + }, + { + name: "project.read, org ID and project ID permitted", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project_grant.read", "project_grant.write"}, + }, + }, + permm: "project.read", + }, + want: `{ + "instance_permitted": false, + "org_ids": ["orgID"], + "project_ids": ["projectID"], + "project_grants": [] + }`, + }, + { + name: "project_grant.read, project grant ID permitted", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project_grant.read", "project_grant.write"}, + }, + }, + permm: "project_grant.read", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [ + { + "project_id": "projectID", + "grant_id": "grantID" + } + ] + }`, + }, + { + name: "instance without aggregate ID", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "", + Permissions: []string{"foo.bar", "bar.foo"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "wrong instance ID", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "wrong", + Permissions: []string{"foo.bar", "bar.foo"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "permission on other instance", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"bar.foo"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "wrong", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "org ID missing", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "multiple org IDs", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "Org1", + Permissions: []string{"foo.bar"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "Org2", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": ["Org1", "Org2"], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "project ID missing", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProject, + AggregateID: "", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "multiple project IDs", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProject, + AggregateID: "P1", + Permissions: []string{"foo.bar"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "P2", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": ["P1", "P2"], + "project_grants": [] + }`, + }, + { + name: "project grant ID missing", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "", + ObjectID: "", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "multiple project IDs", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "P1", + ObjectID: "O1", + Permissions: []string{"foo.bar"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "P2", + ObjectID: "O2", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [ + { + "project_id": "P1", + "grant_id": "O1" + }, + { + "project_id": "P2", + "grant_id": "O2" + } + ] + }`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rows, err := dbPool.Query(CTX, query, database.NewJSONArray(tt.args.permissions), tt.args.reqInstanceID, tt.args.permm) + require.NoError(t, err) + got, err := pgx.CollectOneRow(rows, pgx.RowTo[string]) + require.NoError(t, err) + assert.JSONEq(t, tt.want, got) + }) + } +} + +const ( + instanceID = "instanceID" + orgID = "orgID" + projectID = "projectID" +) + +func TestPermittedOrgs(t *testing.T) { + t.Parallel() + + tx, err := dbPool.Begin(CTX) + require.NoError(t, err) + defer tx.Rollback(CTX) + + // Insert a couple of deterministic field rows to test the function. + // Data will not persist, because the transaction is rolled back. + createRolePermission(t, tx, "IAM_OWNER", []string{"org.write", "org.read"}) + createRolePermission(t, tx, "ORG_OWNER", []string{"org.write", "org.read"}) + createMember(t, tx, instance.AggregateType, "instance_user") + createMember(t, tx, org.AggregateType, "org_user") + + const query = "SELECT instance_permitted, org_ids FROM eventstore.permitted_orgs($1,$2,$3,$4,$5);" + type args struct { + reqInstanceID string + authUserID string + systemUserPerms []authz.SystemUserPermissions + perm string + filterOrg *string + } + type result struct { + InstancePermitted bool + OrgIDs pgtype.FlatArray[string] + } + tests := []struct { + name string + args args + want result + }{ + { + name: "system user, instance", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"org.write", "org.read"}, + }}, + perm: "org.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "system user, orgs", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }}, + perm: "org.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "instance member", + args: args{ + reqInstanceID: instanceID, + authUserID: "instance_user", + perm: "org.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "org member", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "org.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "org.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter wrong org", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "org.read", + filterOrg: gu.Ptr("foobar"), + }, + want: result{}, + }, + { + name: "no permission", + args: args{ + reqInstanceID: instanceID, + authUserID: "foobar", + perm: "org.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, err := tx.Query(CTX, query, tt.args.reqInstanceID, tt.args.authUserID, database.NewJSONArray(tt.args.systemUserPerms), tt.args.perm, tt.args.filterOrg) + require.NoError(t, err) + got, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[result]) + require.NoError(t, err) + assert.Equal(t, tt.want.InstancePermitted, got.InstancePermitted) + assert.ElementsMatch(t, tt.want.OrgIDs, got.OrgIDs) + }) + } +} + +func TestPermittedProjects(t *testing.T) { + t.Parallel() + + tx, err := dbPool.Begin(CTX) + require.NoError(t, err) + defer tx.Rollback(CTX) + + // Insert a couple of deterministic field rows to test the function. + // Data will not persist, because the transaction is rolled back. + createRolePermission(t, tx, "IAM_OWNER", []string{"project.write", "project.read"}) + createRolePermission(t, tx, "ORG_OWNER", []string{"project.write", "project.read"}) + createRolePermission(t, tx, "PROJECT_OWNER", []string{"project.write", "project.read"}) + createMember(t, tx, instance.AggregateType, "instance_user") + createMember(t, tx, org.AggregateType, "org_user") + createMember(t, tx, project.AggregateType, "project_user") + + const query = "SELECT instance_permitted, org_ids, project_ids FROM eventstore.permitted_projects($1,$2,$3,$4,$5);" + type args struct { + reqInstanceID string + authUserID string + systemUserPerms []authz.SystemUserPermissions + perm string + filterOrg *string + } + type result struct { + InstancePermitted bool + OrgIDs pgtype.FlatArray[string] + ProjectIDs pgtype.FlatArray[string] + } + tests := []struct { + name string + args args + want result + }{ + { + name: "system user, instance", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"project.write", "project.read"}, + }}, + perm: "project.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "system user, orgs", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Permissions: []string{"project.read", "project.write"}, + }}, + perm: "project.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "system user, projects", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeProject, + AggregateID: projectID, + Permissions: []string{"project.read", "project.write"}, + }}, + perm: "project.read", + }, + want: result{ + ProjectIDs: pgtype.FlatArray[string]{projectID}, + }, + }, + { + name: "system user, org and project", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: projectID, + Permissions: []string{"project.read", "project.write"}, + }, + }, + perm: "project.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + ProjectIDs: pgtype.FlatArray[string]{projectID}, + }, + }, + { + name: "instance member", + args: args{ + reqInstanceID: instanceID, + authUserID: "instance_user", + perm: "project.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "org member", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "project.read", + }, + want: result{ + InstancePermitted: false, + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "project.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{ + InstancePermitted: false, + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter wrong org", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "project.read", + filterOrg: gu.Ptr("foobar"), + }, + want: result{}, + }, + { + name: "project member", + args: args{ + reqInstanceID: instanceID, + authUserID: "project_user", + perm: "project.read", + }, + want: result{ + ProjectIDs: pgtype.FlatArray[string]{projectID}, + }, + }, + { + name: "no permission", + args: args{ + reqInstanceID: instanceID, + authUserID: "foobar", + perm: "project.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, err := tx.Query(CTX, query, tt.args.reqInstanceID, tt.args.authUserID, database.NewJSONArray(tt.args.systemUserPerms), tt.args.perm, tt.args.filterOrg) + require.NoError(t, err) + got, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[result]) + require.NoError(t, err) + assert.Equal(t, tt.want.InstancePermitted, got.InstancePermitted) + assert.ElementsMatch(t, tt.want.OrgIDs, got.OrgIDs) + }) + } +} + +func createRolePermission(t *testing.T, tx pgx.Tx, role string, permissions []string) { + for _, perm := range permissions { + createTestField(t, tx, instanceID, permission.AggregateType, instanceID, "role_permission", role, "permission", perm) + } +} + +func createMember(t *testing.T, tx pgx.Tx, aggregateType eventstore.AggregateType, userID string) { + var err error + switch aggregateType { + case instance.AggregateType: + createTestField(t, tx, instanceID, aggregateType, instanceID, "instance_member_role", userID, "instance_role", "IAM_OWNER") + case org.AggregateType: + createTestField(t, tx, orgID, aggregateType, orgID, "org_member_role", userID, "org_role", "ORG_OWNER") + case project.AggregateType: + createTestField(t, tx, orgID, aggregateType, orgID, "project_member_role", userID, "project_role", "PROJECT_OWNER") + default: + panic("unknown aggregate type " + aggregateType) + } + require.NoError(t, err) +} + +func createTestField(t *testing.T, tx pgx.Tx, resourceOwner string, aggregateType eventstore.AggregateType, aggregateID, objectType, objectID, fieldName string, value any) { + const query = `INSERT INTO eventstore.fields( + instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, field_name, value, value_must_be_unique, should_index, object_revision) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, true, 1);` + encValue, err := json.Marshal(value) + require.NoError(t, err) + _, err = tx.Exec(CTX, query, instanceID, resourceOwner, aggregateType, aggregateID, objectType, objectID, fieldName, encValue) + require.NoError(t, err) + +} diff --git a/cmd/setup/integration_test/setup_test.go b/cmd/setup/integration_test/setup_test.go new file mode 100644 index 0000000000..42b8502841 --- /dev/null +++ b/cmd/setup/integration_test/setup_test.go @@ -0,0 +1,41 @@ +// Package setup_test implements tests for procedural PostgreSQL functions, +// created in the database during Zitadel setup. +// Tests depend on `zitadel setup` being run first and therefore is run as integration tests. +// A PGX connection is used directly to the integration test database. +// This package assumes the database server available as per integration test defaults. +// See the [ConnString] constant. + +//go:build integration + +package setup_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ConnString = "host=localhost port=5432 user=zitadel dbname=zitadel sslmode=disable" + +var ( + CTX context.Context + dbPool *pgxpool.Pool +) + +func TestMain(m *testing.M) { + var cancel context.CancelFunc + CTX, cancel = context.WithTimeout(context.Background(), time.Second*10) + + var err error + dbPool, err = pgxpool.New(context.Background(), ConnString) + if err != nil { + panic(err) + } + exit := m.Run() + cancel() + dbPool.Close() + os.Exit(exit) +} diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index d12a1def44..ff2fa8d445 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -1,4 +1,4 @@ -//go:generate enumer -type MemberType -trimprefix MemberType -json +//go:generate enumer -type MemberType -trimprefix MemberType -json -sql package authz diff --git a/internal/api/authz/membertype_enumer.go b/internal/api/authz/membertype_enumer.go index a4275a2254..9354194660 100644 --- a/internal/api/authz/membertype_enumer.go +++ b/internal/api/authz/membertype_enumer.go @@ -1,8 +1,9 @@ -// Code generated by "enumer -type MemberType -trimprefix MemberType -json"; DO NOT EDIT. +// Code generated by "enumer -type MemberType -trimprefix MemberType -json -sql"; DO NOT EDIT. package authz import ( + "database/sql/driver" "encoding/json" "fmt" "strings" @@ -110,3 +111,33 @@ func (i *MemberType) UnmarshalJSON(data []byte) error { *i, err = MemberTypeString(s) return err } + +func (i MemberType) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *MemberType) Scan(value interface{}) error { + if value == nil { + return nil + } + + var str string + switch v := value.(type) { + case []byte: + str = string(v) + case string: + str = v + case fmt.Stringer: + str = v.String() + default: + return fmt.Errorf("invalid value of MemberType: %[1]T(%[1]v)", value) + } + + val, err := MemberTypeString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/internal/query/idp_user_link.go b/internal/query/idp_user_link.go index 99bf3c403b..7f162f235e 100644 --- a/internal/query/idp_user_link.go +++ b/internal/query/idp_user_link.go @@ -110,13 +110,14 @@ func idpLinksPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enab if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, IDPUserLinkResourceOwnerCol, domain.PermissionUserRead, SingleOrgPermissionOption(queries.Queries), OwnedRowsPermissionOption(IDPUserLinkUserIDCol), - )) + ) + return query.JoinClause(join, args...) } func (q *Queries) IDPUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, permissionCheck domain.PermissionCheck) (idps *IDPUserLinks, err error) { diff --git a/internal/query/org.go b/internal/query/org.go index 643aec291a..dfe90ad9f8 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -97,11 +97,12 @@ func orgsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, OrgColumnID, domain_pkg.PermissionOrgRead, - )) + ) + return query.JoinClause(join, args...) } type OrgSearchQueries struct { diff --git a/internal/query/permission.go b/internal/query/permission.go index 3157430264..19e3ed984e 100644 --- a/internal/query/permission.go +++ b/internal/query/permission.go @@ -2,7 +2,6 @@ package query import ( "context" - "fmt" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" @@ -10,41 +9,66 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" domain_pkg "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" ) const ( - // eventstore.permitted_orgs(instanceid text, userid text, system_user_perms JSONB, perm text, filter_org text) - wherePermittedOrgsExpr = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))" + // eventstore.permitted_orgs(req_instance_id text, auth_user_id text, system_user_perms JSONB, perm text, filter_org text) + joinPermittedOrgsFunction = `INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON ` + + // eventstore.permitted_projects(req_instance_id text, auth_user_id text, system_user_perms JSONB, perm text, filter_org text) + joinPermittedProjectsFunction = `INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON ` ) +// permissionClauseBuilder is used to build the SQL clause for permission checks. +// Don't use it directly, use the [PermissionClause] function with proper options instead. type permissionClauseBuilder struct { orgIDColumn Column instanceID string userID string systemPermissions []authz.SystemUserPermissions permission string - orgID string - connections []sq.Eq + + // optional fields + orgID *string + projectIDColumn *Column + connections []sq.Eq } func (b *permissionClauseBuilder) appendConnection(column string, value any) { b.connections = append(b.connections, sq.Eq{column: value}) } -func (b *permissionClauseBuilder) clauses() sq.Or { - clauses := make(sq.Or, 1, len(b.connections)+1) - clauses[0] = sq.Expr( - fmt.Sprintf(wherePermittedOrgsExpr, b.orgIDColumn.identifier()), +// joinFunction picks the correct SQL function and return the required arguments for that function. +func (b *permissionClauseBuilder) joinFunction() (sql string, args []any) { + sql = joinPermittedOrgsFunction + if b.projectIDColumn != nil { + sql = joinPermittedProjectsFunction + } + return sql, []any{ b.instanceID, b.userID, database.NewJSONArray(b.systemPermissions), b.permission, b.orgID, - ) - for _, include := range b.connections { - clauses = append(clauses, include) } - return clauses +} + +// joinConditions returns the conditions for the join, +// which are dynamic based on the provided options. +func (b *permissionClauseBuilder) joinConditions() sq.Or { + conditions := make(sq.Or, 2, len(b.connections)+3) + conditions[0] = sq.Expr("permissions.instance_permitted") + conditions[1] = sq.Expr(b.orgIDColumn.identifier() + " = ANY(permissions.org_ids)") + if b.projectIDColumn != nil { + conditions = append(conditions, + sq.Expr(b.projectIDColumn.identifier()+" = ANY(permissions.project_ids)"), + ) + } + for _, c := range b.connections { + conditions = append(conditions, c) + } + return conditions } type PermissionOption func(b *permissionClauseBuilder) @@ -52,6 +76,8 @@ type PermissionOption func(b *permissionClauseBuilder) // OwnedRowsPermissionOption allows rows to be returned of which the current user is the owner. // Even if the user does not have an explicit permission for the organization. // For example an authenticated user can always see his own user account. +// This option may be provided multiple times to allow matching with multiple columns. +// See [ConnectionPermissionOption] for more details. func OwnedRowsPermissionOption(userIDColumn Column) PermissionOption { return func(b *permissionClauseBuilder) { b.appendConnection(userIDColumn.identifier(), b.userID) @@ -59,7 +85,10 @@ func OwnedRowsPermissionOption(userIDColumn Column) PermissionOption { } // ConnectionPermissionOption allows returning of rows where the value is matched. -// Even if the user does not have an explicit permission for the organization. +// Even if the user does not have an explicit permission for the resource. +// Multiple connections may be provided. +// Each connection is applied in a OR condition, so if previous permissions are not met, +// matching rows are still returned for a later match. func ConnectionPermissionOption(column Column, value any) PermissionOption { return func(b *permissionClauseBuilder) { b.appendConnection(column.identifier(), value) @@ -70,15 +99,28 @@ func ConnectionPermissionOption(column Column, value any) PermissionOption { // returned organizations, to the one used in the requested filters. func SingleOrgPermissionOption(queries []SearchQuery) PermissionOption { return func(b *permissionClauseBuilder) { - b.orgID = findTextEqualsQuery(b.orgIDColumn, queries) + orgID, ok := findTextEqualsQuery(b.orgIDColumn, queries) + if ok { + b.orgID = &orgID + } } } -// PermissionClause sets a `WHERE` clause to query, -// which filters returned rows the current authenticated user has the requested permission to. +// WithProjectsPermissionOption sets an additional filter against the project ID column, +// allowing for project specific permissions. +func WithProjectsPermissionOption(projectIDColumn Column) PermissionOption { + return func(b *permissionClauseBuilder) { + b.projectIDColumn = &projectIDColumn + } +} + +// PermissionClause builds a `INNER JOIN` clause which can be applied to a query builder. +// It filters returned rows the current authenticated user has the requested permission to. +// See permission_example_test.go for examples. // -// Experimental: Work in progress. Currently only organization permissions are supported -func PermissionClause(ctx context.Context, orgIDCol Column, permission string, options ...PermissionOption) sq.Or { +// Experimental: Work in progress. Currently only organization and project permissions are supported +// TODO: Add support for project grants. +func PermissionClause(ctx context.Context, orgIDCol Column, permission string, options ...PermissionOption) (string, []any) { ctxData := authz.GetCtxData(ctx) b := &permissionClauseBuilder{ orgIDColumn: orgIDCol, @@ -97,10 +139,18 @@ func PermissionClause(ctx context.Context, orgIDCol Column, permission string, o "system_user_permissions", b.systemPermissions, "permission", b.permission, "org_id", b.orgID, - "overrides", b.connections, + "project_id_column", b.projectIDColumn, + "connections", b.connections, ).Debug("permitted orgs check used") - return b.clauses() + sql, args := b.joinFunction() + conditions, conditionArgs, err := b.joinConditions().ToSql() + if err != nil { + // all cases are tested, no need to return an error. + // If an error does happen, it's a bug and not a user error. + panic(zerrors.ThrowInternal(err, "PERMISSION-OoS5o", "Errors.Internal")) + } + return sql + conditions, append(args, conditionArgs...) } // PermissionV2 checks are enabled when the feature flag is set and the permission check function is not nil. diff --git a/internal/query/permission_example_test.go b/internal/query/permission_example_test.go new file mode 100644 index 0000000000..6211ad0bb2 --- /dev/null +++ b/internal/query/permission_example_test.go @@ -0,0 +1,78 @@ +package query + +import ( + "context" + "fmt" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" +) + +// ExamplePermissionClause_org shows how to use the PermissionClause function to filter +// permitted records based on the resource owner and the user's instance or organization membership. +func ExamplePermissionClause_org() { + // These variables are typically set in the middleware of Zitadel. + // They do not influence the generation of the clause, just what + // the function does in Postgres. + ctx := authz.WithInstanceID(context.Background(), "instanceID") + ctx = authz.SetCtxData(ctx, authz.CtxData{ + UserID: "userID", + }) + + join, args := PermissionClause( + ctx, + UserResourceOwnerCol, // match the resource owner column + domain.PermissionUserRead, + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewUserDisplayNameSearchQuery("zitadel", TextContains)), + mustSearchQuery(NewUserResourceOwnerSearchQuery("orgID", TextEquals)), + }), // If the request had an orgID filter, it can be used to optimize the SQL function. + OwnedRowsPermissionOption(UserIDCol), // allow user to find themselves. + ) + + sql, _, _ := sq.Select("*"). + From(userTable.identifier()). + JoinClause(join, args...). + Where(sq.Eq{ + UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + }).ToSql() + fmt.Println(sql) + // Output: + // SELECT * FROM projections.users14 INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = ?) WHERE projections.users14.instance_id = ? +} + +// ExamplePermissionClause_project shows how to use the PermissionClause function to filter +// permitted records based on the resource owner and the user's instance or organization membership. +// Additionally, it allows returning records based on the project ID and project membership. +func ExamplePermissionClause_project() { + // These variables are typically set in the middleware of Zitadel. + // They do not influence the generation of the clause, just what + // the function does in Postgres. + ctx := authz.WithInstanceID(context.Background(), "instanceID") + ctx = authz.SetCtxData(ctx, authz.CtxData{ + UserID: "userID", + }) + + join, args := PermissionClause( + ctx, + ProjectColumnResourceOwner, // match the resource owner column + "project.read", + WithProjectsPermissionOption(ProjectColumnID), + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewUserDisplayNameSearchQuery("zitadel", TextContains)), + mustSearchQuery(NewUserResourceOwnerSearchQuery("orgID", TextEquals)), + }), // If the request had an orgID filter, it can be used to optimize the SQL function. + ) + + sql, _, _ := sq.Select("*"). + From(projectsTable.identifier()). + JoinClause(join, args...). + Where(sq.Eq{ + ProjectColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }).ToSql() + fmt.Println(sql) + // Output: + // SELECT * FROM projections.projects4 INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.projects4.resource_owner = ANY(permissions.org_ids) OR projections.projects4.id = ANY(permissions.project_ids)) WHERE projections.projects4.instance_id = ? +} diff --git a/internal/query/permission_test.go b/internal/query/permission_test.go index f6ecd94b46..24692a9406 100644 --- a/internal/query/permission_test.go +++ b/internal/query/permission_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -38,30 +38,29 @@ func TestPermissionClause(t *testing.T) { options []PermissionOption } tests := []struct { - name string - args args - wantClause sq.Or + name string + args args + wantSql string + wantArgs []any }{ { - name: "no options", + name: "org, no options", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, permission: "permission1", }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "", - ), + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), }, }, { - name: "owned rows option", + name: "org, owned rows option", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, @@ -70,20 +69,18 @@ func TestPermissionClause(t *testing.T) { OwnedRowsPermissionOption(UserIDCol), }, }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "", - ), - sq.Eq{"projections.users14.id": "userID"}, + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = ?)", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), + "userID", }, }, { - name: "connection rows option", + name: "org, connection rows option", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, @@ -93,21 +90,19 @@ func TestPermissionClause(t *testing.T) { ConnectionPermissionOption(UserStateCol, "bar"), }, }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "", - ), - sq.Eq{"projections.users14.id": "userID"}, - sq.Eq{"projections.users14.state": "bar"}, + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = ? OR projections.users14.state = ?)", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), + "userID", + "bar", }, }, { - name: "single org option", + name: "org, with ID", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, @@ -119,22 +114,62 @@ func TestPermissionClause(t *testing.T) { }), }, }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "orgID", - ), + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + gu.Ptr("orgID"), + }, + }, + { + name: "project", + args: args{ + ctx: ctx, + orgIDCol: ProjectColumnResourceOwner, + permission: "permission1", + options: []PermissionOption{ + WithProjectsPermissionOption(ProjectColumnID), + }, + }, + wantSql: "INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.projects4.resource_owner = ANY(permissions.org_ids) OR projections.projects4.id = ANY(permissions.project_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), + }, + }, + { + name: "project, single org", + args: args{ + ctx: ctx, + orgIDCol: ProjectColumnResourceOwner, + permission: "permission1", + options: []PermissionOption{ + WithProjectsPermissionOption(ProjectColumnID), + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewProjectResourceOwnerSearchQuery("orgID")), + }), + }, + }, + wantSql: "INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.projects4.resource_owner = ANY(permissions.org_ids) OR projections.projects4.id = ANY(permissions.project_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + gu.Ptr("orgID"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotClause := PermissionClause(tt.args.ctx, tt.args.orgIDCol, tt.args.permission, tt.args.options...) - assert.Equal(t, tt.wantClause, gotClause) + gotSql, gotArgs := PermissionClause(tt.args.ctx, tt.args.orgIDCol, tt.args.permission, tt.args.options...) + assert.Equal(t, tt.wantSql, gotSql) + assert.Equal(t, tt.wantArgs, gotArgs) }) } } diff --git a/internal/query/query.go b/internal/query/query.go index bd50d3c0be..e2e7f58ffc 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -149,15 +149,15 @@ func triggerBatch(ctx context.Context, handlers ...*handler.Handler) { wg.Wait() } -func findTextEqualsQuery(column Column, queries []SearchQuery) string { +func findTextEqualsQuery(column Column, queries []SearchQuery) (text string, ok bool) { for _, query := range queries { if query.Col() != column { continue } tq, ok := query.(*textQuery) if ok && tq.Compare == TextEquals { - return tq.Text + return tq.Text, true } } - return "" + return "", false } diff --git a/internal/query/session.go b/internal/query/session.go index 004f29fe81..ff0cbd8d42 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -117,7 +117,7 @@ func sessionsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enab if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, SessionColumnResourceOwner, domain.PermissionSessionRead, @@ -125,8 +125,10 @@ func sessionsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enab OwnedRowsPermissionOption(SessionColumnCreator), // Allow if session belongs to the user OwnedRowsPermissionOption(SessionColumnUserID), + // Allow if session belongs to the same useragent ConnectionPermissionOption(SessionColumnUserAgentFingerprintID, authz.GetCtxData(ctx).AgentID), - )) + ) + return query.JoinClause(join, args...) } func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { diff --git a/internal/query/user.go b/internal/query/user.go index 47694736c4..a97e3bbd14 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -136,13 +136,14 @@ func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, UserResourceOwnerCol, domain.PermissionUserRead, SingleOrgPermissionOption(queries.Queries), OwnedRowsPermissionOption(UserIDCol), - )) + ) + return query.JoinClause(join, args...) } type UserSearchQueries struct { diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index acf61bf0e6..fce34967cf 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -108,12 +108,13 @@ func userAuthMethodPermissionCheckV2(ctx context.Context, query sq.SelectBuilder if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, UserAuthMethodColumnResourceOwner, domain.PermissionUserRead, OwnedRowsPermissionOption(UserIDCol), - )) + ) + return query.JoinClause(join, args...) } type AuthMethod struct {