From 764fd629668e679b1154c9f15ce529b7c1d48517 Mon Sep 17 00:00:00 2001 From: George Katsitadze Date: Mon, 15 Dec 2025 15:28:45 -0800 Subject: [PATCH 1/5] wip: (re-)implement organization-member as custom role --- coderd/coderd.go | 11 ++ coderd/database/dbauthz/dbauthz.go | 4 + coderd/database/dump.sql | 9 +- coderd/database/lock.go | 1 + .../000405_add_system_role_support.down.sql | 3 + .../000405_add_system_role_support.up.sql | 10 ++ ...06_add_workspace_sharing_disabled.down.sql | 1 + ...0406_add_workspace_sharing_disabled.up.sql | 2 + ...07_create_org_member_system_roles.down.sql | 6 + ...0407_create_org_member_system_roles.up.sql | 37 +++++ coderd/database/models.go | 22 +-- coderd/database/queries.sql.go | 97 ++++++++---- coderd/database/queries/roles.sql | 16 ++ coderd/database/sqlc.yaml | 3 + coderd/rbac/roles.go | 136 +++++++++++++---- coderd/rbac/roles_internal_test.go | 8 +- coderd/rbac/rolestore/rolestore.go | 33 +++- coderd/systemroles.go | 143 ++++++++++++++++++ docs/admin/security/audit-logs.md | 4 +- enterprise/audit/table.go | 33 ++-- enterprise/coderd/organizations.go | 23 +++ 21 files changed, 502 insertions(+), 100 deletions(-) create mode 100644 coderd/database/migrations/000405_add_system_role_support.down.sql create mode 100644 coderd/database/migrations/000405_add_system_role_support.up.sql create mode 100644 coderd/database/migrations/000406_add_workspace_sharing_disabled.down.sql create mode 100644 coderd/database/migrations/000406_add_workspace_sharing_disabled.up.sql create mode 100644 coderd/database/migrations/000407_create_org_member_system_roles.down.sql create mode 100644 coderd/database/migrations/000407_create_org_member_system_roles.up.sql create mode 100644 coderd/systemroles.go diff --git a/coderd/coderd.go b/coderd/coderd.go index e08a2a3036885..36608275d3fe2 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -573,6 +573,17 @@ func New(options *Options) *API { // bugs that may only occur when a key isn't precached in tests and the latency cost is minimal. cryptokeys.StartRotator(ctx, options.Logger, options.Database) + // Ensure system role permissions are current for all organizations. + // This backfills permissions for roles created by migration and + // updates permissions when new RBAC resources are added, while + // using advisory lock for multi-replica safety. + err = ReconcileOrgMemberRoles(ctx, options.Logger, options.Database) + if err != nil { + // TODO:(geokat) Not using fatal here and just continuing after + // logging the error would be a potential security hole, right? + options.Logger.Fatal(ctx, "failed to reconcile orgMember role permissions", slog.Error(err)) + } + // AGPL uses a no-op build usage checker as there are no license // entitlements to enforce. This is swapped out in // enterprise/coderd/coderd.go. diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 4962949f7fd49..58b70672a675e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4110,6 +4110,8 @@ func (q *querier) InsertCustomRole(ctx context.Context, arg database.InsertCusto return database.CustomRole{}, err } + // TODO(geokat): should we add MemberPermissions validation too + // now that we have them in system roles? if err := q.customRoleCheck(ctx, database.CustomRole{ Name: arg.Name, DisplayName: arg.DisplayName, @@ -4864,6 +4866,8 @@ func (q *querier) UpdateCustomRole(ctx context.Context, arg database.UpdateCusto return database.CustomRole{}, err } + // TODO(geokat): should we add MemberPermissions validation too + // now that we have them in system roles? if err := q.customRoleCheck(ctx, database.CustomRole{ Name: arg.Name, DisplayName: arg.DisplayName, diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 790659669f004..84aad7c54be54 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -1202,7 +1202,9 @@ CREATE TABLE custom_roles ( created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, organization_id uuid, - id uuid DEFAULT gen_random_uuid() NOT NULL + id uuid DEFAULT gen_random_uuid() NOT NULL, + is_system boolean DEFAULT false NOT NULL, + member_permissions jsonb DEFAULT '[]'::jsonb NOT NULL ); COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime'; @@ -1211,6 +1213,8 @@ COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scope COMMENT ON COLUMN custom_roles.id IS 'Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.'; +COMMENT ON COLUMN custom_roles.is_system IS 'System roles are managed by Coder and cannot be modified or deleted by users.'; + CREATE TABLE dbcrypt_keys ( number integer NOT NULL, active_key_digest text, @@ -1594,7 +1598,8 @@ CREATE TABLE organizations ( is_default boolean DEFAULT false NOT NULL, display_name text NOT NULL, icon text DEFAULT ''::text NOT NULL, - deleted boolean DEFAULT false NOT NULL + deleted boolean DEFAULT false NOT NULL, + workspace_sharing_disabled boolean DEFAULT false NOT NULL ); CREATE TABLE parameter_schemas ( diff --git a/coderd/database/lock.go b/coderd/database/lock.go index e5091cdfd29cc..333ad852e9e6d 100644 --- a/coderd/database/lock.go +++ b/coderd/database/lock.go @@ -13,6 +13,7 @@ const ( LockIDNotificationsReportGenerator LockIDCryptoKeyRotation LockIDReconcilePrebuilds + LockIDReconcileOrgMemberRoles ) // GenLockID generates a unique and consistent lock ID from a given string. diff --git a/coderd/database/migrations/000405_add_system_role_support.down.sql b/coderd/database/migrations/000405_add_system_role_support.down.sql new file mode 100644 index 0000000000000..8198c7b1c7431 --- /dev/null +++ b/coderd/database/migrations/000405_add_system_role_support.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE custom_roles DROP COLUMN IF EXISTS member_permissions; + +ALTER TABLE custom_roles DROP COLUMN IF EXISTS is_system; diff --git a/coderd/database/migrations/000405_add_system_role_support.up.sql b/coderd/database/migrations/000405_add_system_role_support.up.sql new file mode 100644 index 0000000000000..9fbc99afb8491 --- /dev/null +++ b/coderd/database/migrations/000405_add_system_role_support.up.sql @@ -0,0 +1,10 @@ +-- Add is_system column to identify system-managed roles. +ALTER TABLE custom_roles + ADD COLUMN is_system boolean NOT NULL DEFAULT false; + +-- Add member_permissions column for member-scoped permissions within an organization. +ALTER TABLE custom_roles + ADD COLUMN member_permissions jsonb NOT NULL DEFAULT '[]'::jsonb; + +COMMENT ON COLUMN custom_roles.is_system IS + 'System roles are managed by Coder and cannot be modified or deleted by users.'; diff --git a/coderd/database/migrations/000406_add_workspace_sharing_disabled.down.sql b/coderd/database/migrations/000406_add_workspace_sharing_disabled.down.sql new file mode 100644 index 0000000000000..cc35c25e868d6 --- /dev/null +++ b/coderd/database/migrations/000406_add_workspace_sharing_disabled.down.sql @@ -0,0 +1 @@ +ALTER TABLE organizations DROP COLUMN IF EXISTS workspace_sharing_disabled; diff --git a/coderd/database/migrations/000406_add_workspace_sharing_disabled.up.sql b/coderd/database/migrations/000406_add_workspace_sharing_disabled.up.sql new file mode 100644 index 0000000000000..a5563107d62e8 --- /dev/null +++ b/coderd/database/migrations/000406_add_workspace_sharing_disabled.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE organizations + ADD COLUMN workspace_sharing_disabled boolean NOT NULL DEFAULT false; diff --git a/coderd/database/migrations/000407_create_org_member_system_roles.down.sql b/coderd/database/migrations/000407_create_org_member_system_roles.down.sql new file mode 100644 index 0000000000000..bb99eaf6d497c --- /dev/null +++ b/coderd/database/migrations/000407_create_org_member_system_roles.down.sql @@ -0,0 +1,6 @@ +-- Remove organization-member system roles created by the up migration. +DELETE FROM + custom_roles +WHERE + name = 'organization-member' + AND is_system = true; diff --git a/coderd/database/migrations/000407_create_org_member_system_roles.up.sql b/coderd/database/migrations/000407_create_org_member_system_roles.up.sql new file mode 100644 index 0000000000000..0f29aaeff2c35 --- /dev/null +++ b/coderd/database/migrations/000407_create_org_member_system_roles.up.sql @@ -0,0 +1,37 @@ +-- Create placeholder organization-member system roles for existing +-- organizations. Permissions are empty here and will be populated by +-- the startup hook. +INSERT INTO custom_roles ( + name, + display_name, + organization_id, + site_permissions, + org_permissions, + user_permissions, + member_permissions, + is_system, + created_at, + updated_at +) +SELECT + 'organization-member', -- reserved role name, so it doesn't exist in DB yet + '', + id, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + true, + NOW(), + NOW() +FROM + organizations +WHERE + deleted = false + AND NOT EXISTS ( + SELECT 1 + FROM custom_roles + WHERE + custom_roles.name = 'organization-member' + AND custom_roles.organization_id = organizations.id + ); diff --git a/coderd/database/models.go b/coderd/database/models.go index 3bb4097f3398c..be9ee776ffb0e 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -3741,6 +3741,9 @@ type CustomRole struct { OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` // Custom roles ID is used purely for auditing purposes. Name is a better unique identifier. ID uuid.UUID `db:"id" json:"id"` + // System roles are managed by Coder and cannot be modified or deleted by users. + IsSystem bool `db:"is_system" json:"is_system"` + MemberPermissions CustomRolePermissions `db:"member_permissions" json:"member_permissions"` } // A table used to store the keys used to encrypt the database. @@ -4006,15 +4009,16 @@ type OAuth2ProviderAppToken struct { } type Organization struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - Description string `db:"description" json:"description"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - IsDefault bool `db:"is_default" json:"is_default"` - DisplayName string `db:"display_name" json:"display_name"` - Icon string `db:"icon" json:"icon"` - Deleted bool `db:"deleted" json:"deleted"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + IsDefault bool `db:"is_default" json:"is_default"` + DisplayName string `db:"display_name" json:"display_name"` + Icon string `db:"icon" json:"icon"` + Deleted bool `db:"deleted" json:"deleted"` + WorkspaceSharingDisabled bool `db:"workspace_sharing_disabled" json:"workspace_sharing_disabled"` } type OrganizationMember struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index c803d569365ca..4acbcf9acc0f0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7808,7 +7808,7 @@ func (q *sqlQuerier) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRole const getDefaultOrganization = `-- name: GetDefaultOrganization :one SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon, deleted + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled FROM organizations WHERE @@ -7830,13 +7830,14 @@ func (q *sqlQuerier) GetDefaultOrganization(ctx context.Context) (Organization, &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ) return i, err } const getOrganizationByID = `-- name: GetOrganizationByID :one SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon, deleted + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled FROM organizations WHERE @@ -7856,13 +7857,14 @@ func (q *sqlQuerier) GetOrganizationByID(ctx context.Context, id uuid.UUID) (Org &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ) return i, err } const getOrganizationByName = `-- name: GetOrganizationByName :one SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon, deleted + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled FROM organizations WHERE @@ -7891,6 +7893,7 @@ func (q *sqlQuerier) GetOrganizationByName(ctx context.Context, arg GetOrganizat &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ) return i, err } @@ -7961,7 +7964,7 @@ func (q *sqlQuerier) GetOrganizationResourceCountByID(ctx context.Context, organ const getOrganizations = `-- name: GetOrganizations :many SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon, deleted + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled FROM organizations WHERE @@ -8005,6 +8008,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ); err != nil { return nil, err } @@ -8021,7 +8025,7 @@ func (q *sqlQuerier) GetOrganizations(ctx context.Context, arg GetOrganizationsP const getOrganizationsByUserID = `-- name: GetOrganizationsByUserID :many SELECT - id, name, description, created_at, updated_at, is_default, display_name, icon, deleted + id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled FROM organizations WHERE @@ -8066,6 +8070,7 @@ func (q *sqlQuerier) GetOrganizationsByUserID(ctx context.Context, arg GetOrgani &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ); err != nil { return nil, err } @@ -8085,7 +8090,7 @@ INSERT INTO organizations (id, "name", display_name, description, icon, created_at, updated_at, is_default) VALUES -- If no organizations exist, and this is the first, make it the default. - ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted + ($1, $2, $3, $4, $5, $6, $7, (SELECT TRUE FROM organizations LIMIT 1) IS NULL) RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled ` type InsertOrganizationParams struct { @@ -8119,6 +8124,7 @@ func (q *sqlQuerier) InsertOrganization(ctx context.Context, arg InsertOrganizat &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ) return i, err } @@ -8134,7 +8140,7 @@ SET icon = $5 WHERE id = $6 -RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted +RETURNING id, name, description, created_at, updated_at, is_default, display_name, icon, deleted, workspace_sharing_disabled ` type UpdateOrganizationParams struct { @@ -8166,6 +8172,7 @@ func (q *sqlQuerier) UpdateOrganization(ctx context.Context, arg UpdateOrganizat &i.DisplayName, &i.Icon, &i.Deleted, + &i.WorkspaceSharingDisabled, ) return i, err } @@ -11927,7 +11934,7 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams) const customRoles = `-- name: CustomRoles :many SELECT - name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id + name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id, is_system, member_permissions FROM custom_roles WHERE @@ -11950,16 +11957,30 @@ WHERE organization_id = $3 ELSE true END + -- Filter system roles. By default, system roles are excluded. + -- System roles are managed by Coder and should be hidden from user-facing APIs. + -- The authorization system uses @include_system_roles = true to load them. + AND CASE WHEN $4 :: boolean THEN + true + ELSE + is_system = false + END ` type CustomRolesParams struct { - LookupRoles []NameOrganizationPair `db:"lookup_roles" json:"lookup_roles"` - ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + LookupRoles []NameOrganizationPair `db:"lookup_roles" json:"lookup_roles"` + ExcludeOrgRoles bool `db:"exclude_org_roles" json:"exclude_org_roles"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + IncludeSystemRoles bool `db:"include_system_roles" json:"include_system_roles"` } func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]CustomRole, error) { - rows, err := q.db.QueryContext(ctx, customRoles, pq.Array(arg.LookupRoles), arg.ExcludeOrgRoles, arg.OrganizationID) + rows, err := q.db.QueryContext(ctx, customRoles, + pq.Array(arg.LookupRoles), + arg.ExcludeOrgRoles, + arg.OrganizationID, + arg.IncludeSystemRoles, + ) if err != nil { return nil, err } @@ -11977,6 +11998,8 @@ func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([] &i.UpdatedAt, &i.OrganizationID, &i.ID, + &i.IsSystem, + &i.MemberPermissions, ); err != nil { return nil, err } @@ -11997,6 +12020,9 @@ DELETE FROM WHERE name = lower($1) AND organization_id = $2 + -- Prevents accidental deletion of system roles even if the API + -- layer check is bypassed due to a bug. + AND is_system = false ` type DeleteCustomRoleParams struct { @@ -12018,6 +12044,8 @@ INSERT INTO site_permissions, org_permissions, user_permissions, + member_permissions, + is_system, created_at, updated_at ) @@ -12029,19 +12057,23 @@ VALUES ( $4, $5, $6, + $7, + $8, now(), now() ) -RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id, is_system, member_permissions ` type InsertCustomRoleParams struct { - Name string `db:"name" json:"name"` - DisplayName string `db:"display_name" json:"display_name"` - OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` - SitePermissions CustomRolePermissions `db:"site_permissions" json:"site_permissions"` - OrgPermissions CustomRolePermissions `db:"org_permissions" json:"org_permissions"` - UserPermissions CustomRolePermissions `db:"user_permissions" json:"user_permissions"` + Name string `db:"name" json:"name"` + DisplayName string `db:"display_name" json:"display_name"` + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` + SitePermissions CustomRolePermissions `db:"site_permissions" json:"site_permissions"` + OrgPermissions CustomRolePermissions `db:"org_permissions" json:"org_permissions"` + UserPermissions CustomRolePermissions `db:"user_permissions" json:"user_permissions"` + MemberPermissions CustomRolePermissions `db:"member_permissions" json:"member_permissions"` + IsSystem bool `db:"is_system" json:"is_system"` } func (q *sqlQuerier) InsertCustomRole(ctx context.Context, arg InsertCustomRoleParams) (CustomRole, error) { @@ -12052,6 +12084,8 @@ func (q *sqlQuerier) InsertCustomRole(ctx context.Context, arg InsertCustomRoleP arg.SitePermissions, arg.OrgPermissions, arg.UserPermissions, + arg.MemberPermissions, + arg.IsSystem, ) var i CustomRole err := row.Scan( @@ -12064,6 +12098,8 @@ func (q *sqlQuerier) InsertCustomRole(ctx context.Context, arg InsertCustomRoleP &i.UpdatedAt, &i.OrganizationID, &i.ID, + &i.IsSystem, + &i.MemberPermissions, ) return i, err } @@ -12076,20 +12112,22 @@ SET site_permissions = $2, org_permissions = $3, user_permissions = $4, + member_permissions = $5, updated_at = now() WHERE - name = lower($5) - AND organization_id = $6 -RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id + name = lower($6) + AND organization_id = $7 +RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id, is_system, member_permissions ` type UpdateCustomRoleParams struct { - DisplayName string `db:"display_name" json:"display_name"` - SitePermissions CustomRolePermissions `db:"site_permissions" json:"site_permissions"` - OrgPermissions CustomRolePermissions `db:"org_permissions" json:"org_permissions"` - UserPermissions CustomRolePermissions `db:"user_permissions" json:"user_permissions"` - Name string `db:"name" json:"name"` - OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` + DisplayName string `db:"display_name" json:"display_name"` + SitePermissions CustomRolePermissions `db:"site_permissions" json:"site_permissions"` + OrgPermissions CustomRolePermissions `db:"org_permissions" json:"org_permissions"` + UserPermissions CustomRolePermissions `db:"user_permissions" json:"user_permissions"` + MemberPermissions CustomRolePermissions `db:"member_permissions" json:"member_permissions"` + Name string `db:"name" json:"name"` + OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` } func (q *sqlQuerier) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleParams) (CustomRole, error) { @@ -12098,6 +12136,7 @@ func (q *sqlQuerier) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleP arg.SitePermissions, arg.OrgPermissions, arg.UserPermissions, + arg.MemberPermissions, arg.Name, arg.OrganizationID, ) @@ -12112,6 +12151,8 @@ func (q *sqlQuerier) UpdateCustomRole(ctx context.Context, arg UpdateCustomRoleP &i.UpdatedAt, &i.OrganizationID, &i.ID, + &i.IsSystem, + &i.MemberPermissions, ) return i, err } diff --git a/coderd/database/queries/roles.sql b/coderd/database/queries/roles.sql index ee5d35d91ab65..8a2ed0cccca13 100644 --- a/coderd/database/queries/roles.sql +++ b/coderd/database/queries/roles.sql @@ -23,6 +23,14 @@ WHERE organization_id = @organization_id ELSE true END + -- Filter system roles. By default, system roles are excluded. + -- System roles are managed by Coder and should be hidden from user-facing APIs. + -- The authorization system uses @include_system_roles = true to load them. + AND CASE WHEN @include_system_roles :: boolean THEN + true + ELSE + is_system = false + END ; -- name: DeleteCustomRole :exec @@ -31,6 +39,9 @@ DELETE FROM WHERE name = lower(@name) AND organization_id = @organization_id + -- Prevents accidental deletion of system roles even if the API + -- layer check is bypassed due to a bug. + AND is_system = false ; -- name: InsertCustomRole :one @@ -42,6 +53,8 @@ INSERT INTO site_permissions, org_permissions, user_permissions, + member_permissions, + is_system, created_at, updated_at ) @@ -53,6 +66,8 @@ VALUES ( @site_permissions, @org_permissions, @user_permissions, + @member_permissions, + @is_system, now(), now() ) @@ -66,6 +81,7 @@ SET site_permissions = @site_permissions, org_permissions = @org_permissions, user_permissions = @user_permissions, + member_permissions = @member_permissions, updated_at = now() WHERE name = lower(@name) diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index a7a821b758e4b..d6a22698454d8 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -53,6 +53,9 @@ sql: - column: "custom_roles.user_permissions" go_type: type: "CustomRolePermissions" + - column: "custom_roles.member_permissions" + go_type: + type: "CustomRolePermissions" - column: "provisioner_daemons.tags" go_type: type: "StringMap" diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 91061f1647020..69f1f03874a72 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -432,39 +432,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) { }, } }, - - // orgMember is an implied role to any member in an organization. - orgMember: func(organizationID uuid.UUID) Role { - return Role{ - Identifier: RoleIdentifier{Name: orgMember, OrganizationID: organizationID}, - DisplayName: "", - Site: []Permission{}, - User: []Permission{}, - ByOrgID: map[string]OrgPermissions{ - organizationID.String(): { - Org: Permissions(map[string][]policy.Action{ - // All users can see the provisioner daemons for workspace - // creation. - ResourceProvisionerDaemon.Type: {policy.ActionRead}, - // All org members can read the organization - ResourceOrganization.Type: {policy.ActionRead}, - // Can read available roles. - ResourceAssignOrgRole.Type: {policy.ActionRead}, - }), - Member: append(allPermsExcept(ResourceWorkspaceDormant, ResourcePrebuiltWorkspace, ResourceUser, ResourceOrganizationMember), - Permissions(map[string][]policy.Action{ - // Reduced permission set on dormant workspaces. No build, ssh, or exec - ResourceWorkspaceDormant.Type: {policy.ActionRead, policy.ActionDelete, policy.ActionCreate, policy.ActionUpdate, policy.ActionWorkspaceStop, policy.ActionCreateAgent, policy.ActionDeleteAgent}, - // Can read their own organization member record - ResourceOrganizationMember.Type: {policy.ActionRead}, - // Users can create provisioner daemons scoped to themselves. - ResourceProvisionerDaemon.Type: {policy.ActionRead, policy.ActionCreate, policy.ActionRead, policy.ActionUpdate}, - })..., - ), - }, - }, - } - }, orgAuditor: func(organizationID uuid.UUID) Role { return Role{ Identifier: RoleIdentifier{Name: orgAuditor, OrganizationID: organizationID}, @@ -916,3 +883,106 @@ func DeduplicatePermissions(perms []Permission) []Permission { } return deduped } + +// PermissionsEqual compares two permission slices using set-based +// comparison. Order does not matter; it only checks that both slices +// contain the same permissions. This is used by the organization +// member system role startup hook to detect when permissions need +// updating. +func PermissionsEqual(a, b []Permission) bool { + if len(a) != len(b) { + return false + } + + set := make(map[string]struct{}, len(a)) + for _, p := range a { + key := p.ResourceType + "\x00" + string(p.Action) + "\x00" + strconv.FormatBool(p.Negate) + set[key] = struct{}{} + } + + for _, p := range b { + key := p.ResourceType + "\x00" + string(p.Action) + "\x00" + strconv.FormatBool(p.Negate) + if _, ok := set[key]; !ok { + return false + } + } + + return true +} + +// OrgMemberPermissions returns the permissions for the organization-member +// system role. The results are stored in the database and can vary per +// organization based on the workspace_sharing_disabled setting. +// +// This is the source of truth for org-member permissions. The startup +// hook uses this to populate and update org-member system roles in +// the database when resources change. The org-wide workspace sharing +// toggle uses it to update the roles to reflect the new setting. +func OrgMemberPermissions(workspaceSharingDisabled bool) ( + orgPerms, memberPerms []Permission, +) { + // Organization-level permissions that all org members get. + orgPermMap := map[string][]policy.Action{ + // All users can see provisioner daemons for workspace creation. + ResourceProvisionerDaemon.Type: {policy.ActionRead}, + // All org members can read the organization. + ResourceOrganization.Type: {policy.ActionRead}, + // Can read available roles. + ResourceAssignOrgRole.Type: {policy.ActionRead}, + } + + // When workspace sharing is enabled, members need to see other org members + // and groups to share workspaces with them. + if !workspaceSharingDisabled { + orgPermMap[ResourceOrganizationMember.Type] = []policy.Action{policy.ActionRead} + orgPermMap[ResourceGroup.Type] = []policy.Action{policy.ActionRead} + } + + orgPerms = Permissions(orgPermMap) + + // Member-scoped permissions (resources owned by the member). + // Uses allPermsExcept to automatically include permissions for new resources. + memberPerms = append( + allPermsExcept( + ResourceWorkspaceDormant, + ResourcePrebuiltWorkspace, + ResourceUser, + ResourceOrganizationMember, + ), + Permissions(map[string][]policy.Action{ + // Reduced permission set on dormant workspaces. No build, + // ssh, or exec. + ResourceWorkspaceDormant.Type: { + policy.ActionRead, + policy.ActionDelete, + policy.ActionCreate, + policy.ActionUpdate, + policy.ActionWorkspaceStop, + policy.ActionCreateAgent, + policy.ActionDeleteAgent, + }, + // Can read their own organization member record. + ResourceOrganizationMember.Type: { + policy.ActionRead, + }, + // Users can create provisioner daemons scoped to themselves. + ResourceProvisionerDaemon.Type: { + policy.ActionRead, + policy.ActionCreate, + policy.ActionUpdate, + }, + })..., + ) + + if workspaceSharingDisabled { + // This negation overrides the wildcard permission from + // allPermsExcept. + memberPerms = append(memberPerms, Permission{ + Negate: true, + ResourceType: ResourceWorkspace.Type, + Action: policy.ActionShare, + }) + } + + return orgPerms, memberPerms +} diff --git a/coderd/rbac/roles_internal_test.go b/coderd/rbac/roles_internal_test.go index b99791b5a1f5b..8de7059e41073 100644 --- a/coderd/rbac/roles_internal_test.go +++ b/coderd/rbac/roles_internal_test.go @@ -74,7 +74,7 @@ func TestRegoInputValue(t *testing.T) { // Expand all roles and make sure we have a good copy. // This is because these tests modify the roles, and we don't want to // modify the original roles. - roles, err := RoleIdentifiers{ScopedRoleOrgMember(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}.Expand() + roles, err := RoleIdentifiers{ScopedRoleOrgAuditor(uuid.New()), ScopedRoleOrgAdmin(uuid.New()), RoleMember()}.Expand() require.NoError(t, err, "failed to expand roles") for i := range roles { // If all cached values are nil, then the role will not use @@ -224,9 +224,9 @@ func TestRoleByName(t *testing.T) { {Role: builtInRoles[orgAdmin](uuid.New())}, {Role: builtInRoles[orgAdmin](uuid.New())}, - {Role: builtInRoles[orgMember](uuid.New())}, - {Role: builtInRoles[orgMember](uuid.New())}, - {Role: builtInRoles[orgMember](uuid.New())}, + {Role: builtInRoles[orgAuditor](uuid.New())}, + {Role: builtInRoles[orgAuditor](uuid.New())}, + {Role: builtInRoles[orgAuditor](uuid.New())}, } for _, c := range testCases { diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index c2189c13b0c1f..2c31be18077e6 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -83,9 +83,10 @@ func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) // the expansion. These roles are no-ops. Should we raise some kind of // warning when this happens? dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{ - LookupRoles: lookupArgs, - ExcludeOrgRoles: false, - OrganizationID: uuid.Nil, + LookupRoles: lookupArgs, + ExcludeOrgRoles: false, + OrganizationID: uuid.Nil, + IncludeSystemRoles: true, }) if err != nil { return nil, xerrors.Errorf("fetch custom roles: %w", err) @@ -105,7 +106,8 @@ func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) return roles, nil } -func convertPermissions(dbPerms []database.CustomRolePermission) []rbac.Permission { +// ConvertDBPermissions converts database permissions to RBAC permissions. +func ConvertDBPermissions(dbPerms []database.CustomRolePermission) []rbac.Permission { n := make([]rbac.Permission, 0, len(dbPerms)) for _, dbPerm := range dbPerms { n = append(n, rbac.Permission{ @@ -117,14 +119,30 @@ func convertPermissions(dbPerms []database.CustomRolePermission) []rbac.Permissi return n } +// ConvertPermissionsToDB converts RBAC permissions to the database +// format. +// +// TODO(geokat): does it belong in this package? +func ConvertPermissionsToDB(perms []rbac.Permission) []database.CustomRolePermission { + dbPerms := make([]database.CustomRolePermission, 0, len(perms)) + for _, perm := range perms { + dbPerms = append(dbPerms, database.CustomRolePermission{ + Negate: perm.Negate, + ResourceType: perm.ResourceType, + Action: perm.Action, + }) + } + return dbPerms +} + // ConvertDBRole should not be used by any human facing apis. It is used // for authz purposes. func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { role := rbac.Role{ Identifier: dbRole.RoleIdentifier(), DisplayName: dbRole.DisplayName, - Site: convertPermissions(dbRole.SitePermissions), - User: convertPermissions(dbRole.UserPermissions), + Site: ConvertDBPermissions(dbRole.SitePermissions), + User: ConvertDBPermissions(dbRole.UserPermissions), } // Org permissions only make sense if an org id is specified. @@ -135,7 +153,8 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { if dbRole.OrganizationID.UUID != uuid.Nil { role.ByOrgID = map[string]rbac.OrgPermissions{ dbRole.OrganizationID.UUID.String(): { - Org: convertPermissions(dbRole.OrgPermissions), + Org: ConvertDBPermissions(dbRole.OrgPermissions), + Member: ConvertDBPermissions(dbRole.MemberPermissions), }, } } diff --git a/coderd/systemroles.go b/coderd/systemroles.go new file mode 100644 index 0000000000000..7d354fa02c6ab --- /dev/null +++ b/coderd/systemroles.go @@ -0,0 +1,143 @@ +package coderd + +import ( + "context" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" +) + +// ReconcileOrgMemberRoles ensures all org-member system roles have +// current permissions based on each organization's workspace_sharing_disabled +// setting: +// +// 1. Backfill permissions for roles created by migration (initially empty) +// +// 2. Update permissions when new RBAC resources are added to the codebase +// +// 3. Ensure permissions match each organization's workspace sharing +// setting (this one is a safety net; the toggle takes care of that) +// +// Uses PostgreSQL advisory lock (LockIDReconcileOrgMemberRoles) to +// safely handle multi-instance deployments. Other coderd instances +// will block until the first instance completes, then find +// permissions already up-to-date. +// +// Uses set-based comparison to avoid unnecessary database writes when +// permissions haven't changed. +func ReconcileOrgMemberRoles(ctx context.Context, logger slog.Logger, db database.Store) error { + //nolint:gocritic // We need to manage system roles + sysCtx := dbauthz.AsSystemRestricted(ctx) + + return db.InTx(func(tx database.Store) error { + // Acquire advisory lock to prevent concurrent updates from + // multiple coderd instances. Other instances will block here + // until we release the lock (when this transaction commits). + err := tx.AcquireLock(sysCtx, database.LockIDReconcileOrgMemberRoles) + if err != nil { + return xerrors.Errorf("acquire reconcile org-member roles lock: %w", err) + } + + // Fetch all organizations with their workspace sharing setting. + orgs, err := tx.GetOrganizations(sysCtx, database.GetOrganizationsParams{}) + if err != nil { + return xerrors.Errorf("fetch organizations: %w", err) + } + + // Fetch all system roles. + // TODO:(geokat) Add a way to filter by name in SQL instead. + systemRoles, err := tx.CustomRoles(sysCtx, database.CustomRolesParams{ + IncludeSystemRoles: true, + }) + if err != nil { + return xerrors.Errorf("fetch custom roles: %w", err) + } + + // Find org-member roles and index by organization ID for quick lookup. + rolesByOrg := make(map[uuid.UUID]database.CustomRole) + for _, role := range systemRoles { + if role.IsSystem && role.Name == rbac.RoleOrgMember() && role.OrganizationID.Valid { + rolesByOrg[role.OrganizationID.UUID] = role + } + } + + for _, org := range orgs { + role, exists := rolesByOrg[org.ID] + if !exists { + // Role doesn't exist; create it. This could happen due to a + // rolling upgrade race condition where an old instance creates + // an org after the migration has already run. + logger.Warn(sysCtx, "org-member role missing for organization, creating", + slog.F("organization_id", org.ID), + slog.F("organization_name", org.Name)) + if err := createOrgMemberRoleForOrg(sysCtx, tx, org); err != nil { + return xerrors.Errorf("create org-member role for org %s: %w", org.ID, err) + } + continue + } + + // Generate expected perms based on org's workspace sharing setting. + expectedOrgPerms, expectedMemberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled) + + storedOrgPerms := rolestore.ConvertDBPermissions(role.OrgPermissions) + storedMemberPerms := rolestore.ConvertDBPermissions(role.MemberPermissions) + + // Compare using set-based comparison (order doesn't matter). + orgPermsMatch := rbac.PermissionsEqual(expectedOrgPerms, storedOrgPerms) + memberPermsMatch := rbac.PermissionsEqual(expectedMemberPerms, storedMemberPerms) + + if !orgPermsMatch || !memberPermsMatch { + logger.Info(sysCtx, "updating org-member role permissions", + slog.F("organization_id", org.ID), + slog.F("organization_name", org.Name), + slog.F("org_perms_changed", !orgPermsMatch), + slog.F("member_perms_changed", !memberPermsMatch)) + + _, err = tx.UpdateCustomRole(sysCtx, database.UpdateCustomRoleParams{ + Name: role.Name, + OrganizationID: role.OrganizationID, + DisplayName: role.DisplayName, + SitePermissions: role.SitePermissions, + OrgPermissions: rolestore.ConvertPermissionsToDB(expectedOrgPerms), + UserPermissions: role.UserPermissions, + MemberPermissions: rolestore.ConvertPermissionsToDB(expectedMemberPerms), + }) + if err != nil { + return xerrors.Errorf("update org-member role for org %s: %w", org.ID, err) + } + } + } + + return nil + }, nil) +} + +// createOrgMemberRoleForOrg creates the org-member system role for an +// org. This is a fallback for organizations that don't have that role +// which can happen due to, say, race conditions during a rolling +// upgrade in multi-instance scenario. +func createOrgMemberRoleForOrg(ctx context.Context, tx database.Store, org database.Organization) error { + orgPerms, memberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled) + + _, err := tx.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: rbac.RoleOrgMember(), + DisplayName: "", + OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true}, + SitePermissions: database.CustomRolePermissions{}, + OrgPermissions: rolestore.ConvertPermissionsToDB(orgPerms), + UserPermissions: database.CustomRolePermissions{}, + MemberPermissions: rolestore.ConvertPermissionsToDB(memberPerms), + IsSystem: true, + }) + if err != nil { + return xerrors.Errorf("insert org-member role: %w", err) + } + + return nil +} diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index 913611af283df..f72eeafc6e09b 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -19,7 +19,7 @@ We track the following resources: | AuditOAuthConvertState
| |
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete | |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| | AuditableOrganizationMember
| |
FieldTracked
created_attrue
organization_idfalse
rolestrue
updated_attrue
user_idtrue
usernametrue
| -| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| +| CustomRole
| |
FieldTracked
created_atfalse
display_nametrue
idfalse
is_systemfalse
member_permissionstrue
nametrue
org_permissionstrue
organization_idfalse
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create | |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | GroupSyncSettings
| |
FieldTracked
auto_create_missing_groupstrue
fieldtrue
legacy_group_name_mappingfalse
mappingtrue
regex_filtertrue
| | HealthSettings
| |
FieldTracked
dismissed_healthcheckstrue
idfalse
| @@ -28,7 +28,7 @@ We track the following resources: | NotificationsSettings
| |
FieldTracked
idfalse
notifier_pausedtrue
| | OAuth2ProviderApp
| |
FieldTracked
callback_urltrue
client_id_issued_atfalse
client_secret_expires_attrue
client_typetrue
client_uritrue
contactstrue
created_atfalse
dynamically_registeredtrue
grant_typestrue
icontrue
idfalse
jwkstrue
jwks_uritrue
logo_uritrue
nametrue
policy_uritrue
redirect_uristrue
registration_access_tokentrue
registration_client_uritrue
response_typestrue
scopetrue
software_idtrue
software_versiontrue
token_endpoint_auth_methodtrue
tos_uritrue
updated_atfalse
| | OAuth2ProviderAppSecret
| |
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| -| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| +| Organization
| |
FieldTracked
created_atfalse
deletedtrue
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
workspace_sharing_disabledtrue
| | OrganizationSyncSettings
| |
FieldTracked
assign_defaulttrue
fieldtrue
mappingtrue
| | PrebuildsSettings
| |
FieldTracked
idfalse
reconciliation_pausedtrue
| | RoleSyncSettings
| |
FieldTracked
fieldtrue
mappingtrue
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 7006c434c609c..8095ff7301247 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -62,12 +62,14 @@ var auditableResourcesTypes = map[any]map[string]Action{ "roles": ActionTrack, }, &database.CustomRole{}: { - "name": ActionTrack, - "display_name": ActionTrack, - "site_permissions": ActionTrack, - "org_permissions": ActionTrack, - "user_permissions": ActionTrack, - "organization_id": ActionIgnore, // Never changes. + "name": ActionTrack, + "display_name": ActionTrack, + "site_permissions": ActionTrack, + "org_permissions": ActionTrack, + "user_permissions": ActionTrack, + "member_permissions": ActionTrack, + "organization_id": ActionIgnore, // Never changes. + "is_system": ActionIgnore, // Never changes. "id": ActionIgnore, "created_at": ActionIgnore, @@ -310,15 +312,16 @@ var auditableResourcesTypes = map[any]map[string]Action{ "secret_prefix": ActionIgnore, }, &database.Organization{}: { - "id": ActionIgnore, - "name": ActionTrack, - "description": ActionTrack, - "deleted": ActionTrack, - "created_at": ActionIgnore, - "updated_at": ActionTrack, - "is_default": ActionTrack, - "display_name": ActionTrack, - "icon": ActionTrack, + "id": ActionIgnore, + "name": ActionTrack, + "description": ActionTrack, + "deleted": ActionTrack, + "created_at": ActionIgnore, + "updated_at": ActionTrack, + "is_default": ActionTrack, + "display_name": ActionTrack, + "icon": ActionTrack, + "workspace_sharing_disabled": ActionTrack, }, &database.NotificationTemplate{}: { "id": ActionIgnore, diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go index 5a7a4eb777f50..1ad1ef19c4e2e 100644 --- a/enterprise/coderd/organizations.go +++ b/enterprise/coderd/organizations.go @@ -12,9 +12,12 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -281,6 +284,26 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create organization: %w", err) } + + // Create the organization-member system role for this organization. + // New organizations have workspace sharing enabled by default. + orgPerms, memberPerms := rbac.OrgMemberPermissions(false) + //nolint:gocritic // We need to create a system role + sysCtx := dbauthz.AsSystemRestricted(ctx) + _, err = tx.InsertCustomRole(sysCtx, database.InsertCustomRoleParams{ + Name: rbac.RoleOrgMember(), + DisplayName: "", + OrganizationID: uuid.NullUUID{UUID: organization.ID, Valid: true}, + SitePermissions: database.CustomRolePermissions{}, + OrgPermissions: rolestore.ConvertPermissionsToDB(orgPerms), + UserPermissions: database.CustomRolePermissions{}, + MemberPermissions: rolestore.ConvertPermissionsToDB(memberPerms), + IsSystem: true, + }) + if err != nil { + return xerrors.Errorf("create organization member system role: %w", err) + } + _, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, UserID: apiKey.UserID, From e6abc2414a7dc34e7d24658a39b226304b701d8a Mon Sep 17 00:00:00 2001 From: George Katsitadze Date: Wed, 17 Dec 2025 21:41:11 -0800 Subject: [PATCH 2/5] refactor(database): remove migration creating empty org-member system roles The startup hook (ReconcileOrgMemberRoles) already handles role creation with advisory locking. The migration only created empty placeholders with no permission effect. --- ...07_create_org_member_system_roles.down.sql | 6 --- ...0407_create_org_member_system_roles.up.sql | 37 ------------------- 2 files changed, 43 deletions(-) delete mode 100644 coderd/database/migrations/000407_create_org_member_system_roles.down.sql delete mode 100644 coderd/database/migrations/000407_create_org_member_system_roles.up.sql diff --git a/coderd/database/migrations/000407_create_org_member_system_roles.down.sql b/coderd/database/migrations/000407_create_org_member_system_roles.down.sql deleted file mode 100644 index bb99eaf6d497c..0000000000000 --- a/coderd/database/migrations/000407_create_org_member_system_roles.down.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Remove organization-member system roles created by the up migration. -DELETE FROM - custom_roles -WHERE - name = 'organization-member' - AND is_system = true; diff --git a/coderd/database/migrations/000407_create_org_member_system_roles.up.sql b/coderd/database/migrations/000407_create_org_member_system_roles.up.sql deleted file mode 100644 index 0f29aaeff2c35..0000000000000 --- a/coderd/database/migrations/000407_create_org_member_system_roles.up.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Create placeholder organization-member system roles for existing --- organizations. Permissions are empty here and will be populated by --- the startup hook. -INSERT INTO custom_roles ( - name, - display_name, - organization_id, - site_permissions, - org_permissions, - user_permissions, - member_permissions, - is_system, - created_at, - updated_at -) -SELECT - 'organization-member', -- reserved role name, so it doesn't exist in DB yet - '', - id, - '[]'::jsonb, - '[]'::jsonb, - '[]'::jsonb, - '[]'::jsonb, - true, - NOW(), - NOW() -FROM - organizations -WHERE - deleted = false - AND NOT EXISTS ( - SELECT 1 - FROM custom_roles - WHERE - custom_roles.name = 'organization-member' - AND custom_roles.organization_id = organizations.id - ); From 5c8bf9b7605c932da24bf1a033567256e6be279e Mon Sep 17 00:00:00 2001 From: George Katsitadze Date: Wed, 17 Dec 2025 22:25:42 -0800 Subject: [PATCH 3/5] chore: improve comment --- coderd/systemroles.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/coderd/systemroles.go b/coderd/systemroles.go index 7d354fa02c6ab..b2baf0605e25f 100644 --- a/coderd/systemroles.go +++ b/coderd/systemroles.go @@ -13,16 +13,13 @@ import ( "github.com/coder/coder/v2/coderd/rbac/rolestore" ) -// ReconcileOrgMemberRoles ensures all org-member system roles have -// current permissions based on each organization's workspace_sharing_disabled -// setting: -// -// 1. Backfill permissions for roles created by migration (initially empty) +// ReconcileOrgMemberRoles ensures all orgs have an org-member system +// role stored in the DB with permissions reflecting current RBAC +// resources and the org's workspace_sharing_disabled setting. // +// 1. Create org-member system roles for orgs that lack one // 2. Update permissions when new RBAC resources are added to the codebase -// -// 3. Ensure permissions match each organization's workspace sharing -// setting (this one is a safety net; the toggle takes care of that) +// 3. Ensure permissions match each org's workspace sharing setting // // Uses PostgreSQL advisory lock (LockIDReconcileOrgMemberRoles) to // safely handle multi-instance deployments. Other coderd instances From 1511f367dc3a6ea697824321b02d090945a4a09b Mon Sep 17 00:00:00 2001 From: George Katsitadze Date: Wed, 17 Dec 2025 22:38:45 -0800 Subject: [PATCH 4/5] refactor: clean up code and remove chatty logs at startup --- coderd/coderd.go | 13 ++- coderd/rbac/roles.go | 12 +-- coderd/rbac/rolestore/rolestore.go | 110 ++++++++++++++++++++++- coderd/systemroles.go | 140 ----------------------------- enterprise/coderd/organizations.go | 21 +---- 5 files changed, 125 insertions(+), 171 deletions(-) delete mode 100644 coderd/systemroles.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 36608275d3fe2..5fcd2d5905ce1 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -573,14 +573,13 @@ func New(options *Options) *API { // bugs that may only occur when a key isn't precached in tests and the latency cost is minimal. cryptokeys.StartRotator(ctx, options.Logger, options.Database) - // Ensure system role permissions are current for all organizations. - // This backfills permissions for roles created by migration and - // updates permissions when new RBAC resources are added, while - // using advisory lock for multi-replica safety. - err = ReconcileOrgMemberRoles(ctx, options.Logger, options.Database) + // Ensure org-member system role permissions are current for all + // organizations. + //nolint:gocritic // We need to manage system roles + err = rolestore.ReconcileOrgMemberRoles(dbauthz.AsSystemRestricted(ctx), options.Logger, options.Database) if err != nil { - // TODO:(geokat) Not using fatal here and just continuing after - // logging the error would be a potential security hole, right? + // Not ideal, but not using Fatal here and just continuing + // after logging the error would be a potential security hole. options.Logger.Fatal(ctx, "failed to reconcile orgMember role permissions", slog.Error(err)) } diff --git a/coderd/rbac/roles.go b/coderd/rbac/roles.go index 69f1f03874a72..2ea9807e32e97 100644 --- a/coderd/rbac/roles.go +++ b/coderd/rbac/roles.go @@ -911,13 +911,15 @@ func PermissionsEqual(a, b []Permission) bool { } // OrgMemberPermissions returns the permissions for the organization-member -// system role. The results are stored in the database and can vary per +// system role. The results are usually stored in the database and can vary per // organization based on the workspace_sharing_disabled setting. // -// This is the source of truth for org-member permissions. The startup -// hook uses this to populate and update org-member system roles in -// the database when resources change. The org-wide workspace sharing -// toggle uses it to update the roles to reflect the new setting. +// This is the source of truth for org-member permissions, used by: +// +// - The startup reconciliation hook, to keep permissions current with +// RBAC resources +// - The workspace sharing toggle endpoint, when updating the setting +// - The org creation endpoint, when creating the system role for new orgs func OrgMemberPermissions(workspaceSharingDisabled bool) ( orgPerms, memberPerms []Permission, ) { diff --git a/coderd/rbac/rolestore/rolestore.go b/coderd/rbac/rolestore/rolestore.go index 2c31be18077e6..71a7b4a12a578 100644 --- a/coderd/rbac/rolestore/rolestore.go +++ b/coderd/rbac/rolestore/rolestore.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/syncmap" @@ -121,8 +122,6 @@ func ConvertDBPermissions(dbPerms []database.CustomRolePermission) []rbac.Permis // ConvertPermissionsToDB converts RBAC permissions to the database // format. -// -// TODO(geokat): does it belong in this package? func ConvertPermissionsToDB(perms []rbac.Permission) []database.CustomRolePermission { dbPerms := make([]database.CustomRolePermission, 0, len(perms)) for _, perm := range perms { @@ -161,3 +160,110 @@ func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) { return role, nil } + +// ReconcileOrgMemberRoles ensures all orgs have an org-member system +// role stored in the DB with permissions reflecting current RBAC +// resources and the org's workspace_sharing_disabled setting. +// +// 1. Create org-member system roles for orgs that lack one +// 2. Update permissions when new RBAC resources are added to the codebase +// 3. Ensure permissions match each org's workspace sharing setting +// +// Uses PostgreSQL advisory lock (LockIDReconcileOrgMemberRoles) to +// safely handle multi-instance deployments. +// +// Uses set-based comparison to avoid unnecessary database writes when +// permissions haven't changed. +func ReconcileOrgMemberRoles(ctx context.Context, logger slog.Logger, db database.Store) error { + return db.InTx(func(tx database.Store) error { + // Acquire advisory lock to prevent concurrent updates from + // multiple coderd instances. Other instances will block here + // until we release the lock (when this transaction commits). + err := tx.AcquireLock(ctx, database.LockIDReconcileOrgMemberRoles) + if err != nil { + return xerrors.Errorf("acquire reconcile org-member roles lock: %w", err) + } + + orgs, err := tx.GetOrganizations(ctx, database.GetOrganizationsParams{}) + if err != nil { + return xerrors.Errorf("fetch organizations: %w", err) + } + + customRoles, err := tx.CustomRoles(ctx, database.CustomRolesParams{ + IncludeSystemRoles: true, + }) + if err != nil { + return xerrors.Errorf("fetch custom roles: %w", err) + } + + // Find org-member roles and index by organization ID for quick lookup. + rolesByOrg := make(map[uuid.UUID]database.CustomRole) + for _, role := range customRoles { + if role.IsSystem && role.Name == rbac.RoleOrgMember() && role.OrganizationID.Valid { + rolesByOrg[role.OrganizationID.UUID] = role + } + } + + for _, org := range orgs { + role, exists := rolesByOrg[org.ID] + if !exists { + if err := CreateOrgMemberRole(ctx, tx, org); err != nil { + return xerrors.Errorf("create org-member role for organization %s: %w", org.ID, err) + } + // Nothing more to do; the new role's perms are up-to-date. + continue + } + + // Generate expected perms based on org's workspace sharing setting. + expectedOrgPerms, expectedMemberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled) + + storedOrgPerms := ConvertDBPermissions(role.OrgPermissions) + storedMemberPerms := ConvertDBPermissions(role.MemberPermissions) + + // Compare using set-based comparison (order doesn't matter). + orgPermsMatch := rbac.PermissionsEqual(expectedOrgPerms, storedOrgPerms) + memberPermsMatch := rbac.PermissionsEqual(expectedMemberPerms, storedMemberPerms) + + if !orgPermsMatch || !memberPermsMatch { + _, err = tx.UpdateCustomRole(ctx, database.UpdateCustomRoleParams{ + Name: role.Name, + OrganizationID: role.OrganizationID, + DisplayName: role.DisplayName, + SitePermissions: role.SitePermissions, + OrgPermissions: ConvertPermissionsToDB(expectedOrgPerms), + UserPermissions: role.UserPermissions, + MemberPermissions: ConvertPermissionsToDB(expectedMemberPerms), + }) + if err != nil { + return xerrors.Errorf("update org-member role for organization %s: %w", org.ID, err) + } + } + } + + return nil + }, nil) +} + +// CreateOrgMemberRole creates the org-member system role for an organization. +// The role can be missing in two cases: +// - We're creating a new organization, or +// - We're deploying to a site that used the old built-in org-member role +func CreateOrgMemberRole(ctx context.Context, tx database.Store, org database.Organization) error { + orgPerms, memberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled) + + _, err := tx.InsertCustomRole(ctx, database.InsertCustomRoleParams{ + Name: rbac.RoleOrgMember(), + DisplayName: "", + OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true}, + SitePermissions: database.CustomRolePermissions{}, + OrgPermissions: ConvertPermissionsToDB(orgPerms), + UserPermissions: database.CustomRolePermissions{}, + MemberPermissions: ConvertPermissionsToDB(memberPerms), + IsSystem: true, + }) + if err != nil { + return xerrors.Errorf("insert org-member role: %w", err) + } + + return nil +} diff --git a/coderd/systemroles.go b/coderd/systemroles.go deleted file mode 100644 index b2baf0605e25f..0000000000000 --- a/coderd/systemroles.go +++ /dev/null @@ -1,140 +0,0 @@ -package coderd - -import ( - "context" - - "github.com/google/uuid" - "golang.org/x/xerrors" - - "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" - "github.com/coder/coder/v2/coderd/rbac" - "github.com/coder/coder/v2/coderd/rbac/rolestore" -) - -// ReconcileOrgMemberRoles ensures all orgs have an org-member system -// role stored in the DB with permissions reflecting current RBAC -// resources and the org's workspace_sharing_disabled setting. -// -// 1. Create org-member system roles for orgs that lack one -// 2. Update permissions when new RBAC resources are added to the codebase -// 3. Ensure permissions match each org's workspace sharing setting -// -// Uses PostgreSQL advisory lock (LockIDReconcileOrgMemberRoles) to -// safely handle multi-instance deployments. Other coderd instances -// will block until the first instance completes, then find -// permissions already up-to-date. -// -// Uses set-based comparison to avoid unnecessary database writes when -// permissions haven't changed. -func ReconcileOrgMemberRoles(ctx context.Context, logger slog.Logger, db database.Store) error { - //nolint:gocritic // We need to manage system roles - sysCtx := dbauthz.AsSystemRestricted(ctx) - - return db.InTx(func(tx database.Store) error { - // Acquire advisory lock to prevent concurrent updates from - // multiple coderd instances. Other instances will block here - // until we release the lock (when this transaction commits). - err := tx.AcquireLock(sysCtx, database.LockIDReconcileOrgMemberRoles) - if err != nil { - return xerrors.Errorf("acquire reconcile org-member roles lock: %w", err) - } - - // Fetch all organizations with their workspace sharing setting. - orgs, err := tx.GetOrganizations(sysCtx, database.GetOrganizationsParams{}) - if err != nil { - return xerrors.Errorf("fetch organizations: %w", err) - } - - // Fetch all system roles. - // TODO:(geokat) Add a way to filter by name in SQL instead. - systemRoles, err := tx.CustomRoles(sysCtx, database.CustomRolesParams{ - IncludeSystemRoles: true, - }) - if err != nil { - return xerrors.Errorf("fetch custom roles: %w", err) - } - - // Find org-member roles and index by organization ID for quick lookup. - rolesByOrg := make(map[uuid.UUID]database.CustomRole) - for _, role := range systemRoles { - if role.IsSystem && role.Name == rbac.RoleOrgMember() && role.OrganizationID.Valid { - rolesByOrg[role.OrganizationID.UUID] = role - } - } - - for _, org := range orgs { - role, exists := rolesByOrg[org.ID] - if !exists { - // Role doesn't exist; create it. This could happen due to a - // rolling upgrade race condition where an old instance creates - // an org after the migration has already run. - logger.Warn(sysCtx, "org-member role missing for organization, creating", - slog.F("organization_id", org.ID), - slog.F("organization_name", org.Name)) - if err := createOrgMemberRoleForOrg(sysCtx, tx, org); err != nil { - return xerrors.Errorf("create org-member role for org %s: %w", org.ID, err) - } - continue - } - - // Generate expected perms based on org's workspace sharing setting. - expectedOrgPerms, expectedMemberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled) - - storedOrgPerms := rolestore.ConvertDBPermissions(role.OrgPermissions) - storedMemberPerms := rolestore.ConvertDBPermissions(role.MemberPermissions) - - // Compare using set-based comparison (order doesn't matter). - orgPermsMatch := rbac.PermissionsEqual(expectedOrgPerms, storedOrgPerms) - memberPermsMatch := rbac.PermissionsEqual(expectedMemberPerms, storedMemberPerms) - - if !orgPermsMatch || !memberPermsMatch { - logger.Info(sysCtx, "updating org-member role permissions", - slog.F("organization_id", org.ID), - slog.F("organization_name", org.Name), - slog.F("org_perms_changed", !orgPermsMatch), - slog.F("member_perms_changed", !memberPermsMatch)) - - _, err = tx.UpdateCustomRole(sysCtx, database.UpdateCustomRoleParams{ - Name: role.Name, - OrganizationID: role.OrganizationID, - DisplayName: role.DisplayName, - SitePermissions: role.SitePermissions, - OrgPermissions: rolestore.ConvertPermissionsToDB(expectedOrgPerms), - UserPermissions: role.UserPermissions, - MemberPermissions: rolestore.ConvertPermissionsToDB(expectedMemberPerms), - }) - if err != nil { - return xerrors.Errorf("update org-member role for org %s: %w", org.ID, err) - } - } - } - - return nil - }, nil) -} - -// createOrgMemberRoleForOrg creates the org-member system role for an -// org. This is a fallback for organizations that don't have that role -// which can happen due to, say, race conditions during a rolling -// upgrade in multi-instance scenario. -func createOrgMemberRoleForOrg(ctx context.Context, tx database.Store, org database.Organization) error { - orgPerms, memberPerms := rbac.OrgMemberPermissions(org.WorkspaceSharingDisabled) - - _, err := tx.InsertCustomRole(ctx, database.InsertCustomRoleParams{ - Name: rbac.RoleOrgMember(), - DisplayName: "", - OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true}, - SitePermissions: database.CustomRolePermissions{}, - OrgPermissions: rolestore.ConvertPermissionsToDB(orgPerms), - UserPermissions: database.CustomRolePermissions{}, - MemberPermissions: rolestore.ConvertPermissionsToDB(memberPerms), - IsSystem: true, - }) - if err != nil { - return xerrors.Errorf("insert org-member role: %w", err) - } - - return nil -} diff --git a/enterprise/coderd/organizations.go b/enterprise/coderd/organizations.go index 1ad1ef19c4e2e..9f1d3a815ea5e 100644 --- a/enterprise/coderd/organizations.go +++ b/enterprise/coderd/organizations.go @@ -12,11 +12,9 @@ import ( "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/rolestore" "github.com/coder/coder/v2/codersdk" ) @@ -285,23 +283,12 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("create organization: %w", err) } - // Create the organization-member system role for this organization. + // Create the org-member system role for this organization. // New organizations have workspace sharing enabled by default. - orgPerms, memberPerms := rbac.OrgMemberPermissions(false) - //nolint:gocritic // We need to create a system role - sysCtx := dbauthz.AsSystemRestricted(ctx) - _, err = tx.InsertCustomRole(sysCtx, database.InsertCustomRoleParams{ - Name: rbac.RoleOrgMember(), - DisplayName: "", - OrganizationID: uuid.NullUUID{UUID: organization.ID, Valid: true}, - SitePermissions: database.CustomRolePermissions{}, - OrgPermissions: rolestore.ConvertPermissionsToDB(orgPerms), - UserPermissions: database.CustomRolePermissions{}, - MemberPermissions: rolestore.ConvertPermissionsToDB(memberPerms), - IsSystem: true, - }) + err = rolestore.CreateOrgMemberRole(ctx, tx, organization) if err != nil { - return xerrors.Errorf("create organization member system role: %w", err) + return xerrors.Errorf("create org-member role for organization %s: %w", + organization.ID, err) } _, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ From a619e7ac0515921b1f7fe68b58c7ca4c9b6e3e10 Mon Sep 17 00:00:00 2001 From: George Katsitadze Date: Thu, 18 Dec 2025 14:45:49 -0800 Subject: [PATCH 5/5] Bring back the migration and add a trigger --- coderd/database/dump.sql | 33 ++++++++ ...07_create_org_member_system_roles.down.sql | 6 ++ ...0407_create_org_member_system_roles.up.sql | 81 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 coderd/database/migrations/000407_create_org_member_system_roles.down.sql create mode 100644 coderd/database/migrations/000407_create_org_member_system_roles.up.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 84aad7c54be54..67ba1115aa12d 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -746,6 +746,37 @@ BEGIN END; $$; +CREATE FUNCTION insert_org_member_system_role() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + INSERT INTO custom_roles ( + name, + display_name, + organization_id, + site_permissions, + org_permissions, + user_permissions, + member_permissions, + is_system, + created_at, + updated_at + ) VALUES ( + 'organization-member', + '', + NEW.id, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + true, + NOW(), + NOW() + ); + RETURN NEW; +END; +$$; + CREATE FUNCTION insert_user_links_fail_if_user_deleted() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -3554,6 +3585,8 @@ CREATE TRIGGER trigger_delete_oauth2_provider_app_token AFTER DELETE ON oauth2_p CREATE TRIGGER trigger_insert_apikeys BEFORE INSERT ON api_keys FOR EACH ROW EXECUTE FUNCTION insert_apikey_fail_if_user_deleted(); +CREATE TRIGGER trigger_insert_org_member_system_role AFTER INSERT ON organizations FOR EACH ROW EXECUTE FUNCTION insert_org_member_system_role(); + CREATE TRIGGER trigger_nullify_next_start_at_on_workspace_autostart_modificati AFTER UPDATE ON workspaces FOR EACH ROW EXECUTE FUNCTION nullify_next_start_at_on_workspace_autostart_modification(); CREATE TRIGGER trigger_update_users AFTER INSERT OR UPDATE ON users FOR EACH ROW WHEN ((new.deleted = true)) EXECUTE FUNCTION delete_deleted_user_resources(); diff --git a/coderd/database/migrations/000407_create_org_member_system_roles.down.sql b/coderd/database/migrations/000407_create_org_member_system_roles.down.sql new file mode 100644 index 0000000000000..9c352650b79a9 --- /dev/null +++ b/coderd/database/migrations/000407_create_org_member_system_roles.down.sql @@ -0,0 +1,6 @@ +-- Drop the trigger and function created by the up migration. +DROP TRIGGER IF EXISTS trigger_insert_org_member_system_role ON organizations; +DROP FUNCTION IF EXISTS insert_org_member_system_role; + +-- Remove organization-member system roles created by the up migration. +DELETE FROM custom_roles WHERE name = 'organization-member' AND is_system = true; diff --git a/coderd/database/migrations/000407_create_org_member_system_roles.up.sql b/coderd/database/migrations/000407_create_org_member_system_roles.up.sql new file mode 100644 index 0000000000000..03ce3e2572e72 --- /dev/null +++ b/coderd/database/migrations/000407_create_org_member_system_roles.up.sql @@ -0,0 +1,81 @@ +-- Create placeholder organization-member system roles for existing +-- organizations. Also add a trigger that creates the placeholder role +-- when an organization is created. Permissions will be empty until +-- populated by the reconciliation routine. +-- +-- Note: why do all this in the database (as opposed to coderd)? Less +-- room for race conditions. If the role doesn't exist when coderd +-- expects it, the only correct option is to panic. On the other hand, +-- a placeholder role with empty permissions is harmless and the +-- reconciliation process is idempotent. + +-- Create roles for the existing organizations. +INSERT INTO custom_roles ( + name, + display_name, + organization_id, + site_permissions, + org_permissions, + user_permissions, + member_permissions, + is_system, + created_at, + updated_at +) +SELECT + 'organization-member', -- reserved role name, so it doesn't exist in DB yet + '', + id, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + true, + NOW(), + NOW() +FROM + organizations +WHERE + NOT EXISTS ( + SELECT 1 + FROM custom_roles + WHERE + custom_roles.name = 'organization-member' + AND custom_roles.organization_id = organizations.id + ); + +-- When we insert a new organization, we also want to create a +-- placeholder org-member system role for it. +CREATE OR REPLACE FUNCTION insert_org_member_system_role() RETURNS trigger AS $$ +BEGIN + INSERT INTO custom_roles ( + name, + display_name, + organization_id, + site_permissions, + org_permissions, + user_permissions, + member_permissions, + is_system, + created_at, + updated_at + ) VALUES ( + 'organization-member', + '', + NEW.id, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + true, + NOW(), + NOW() + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_insert_org_member_system_role + AFTER INSERT ON organizations + FOR EACH ROW + EXECUTE FUNCTION insert_org_member_system_role();