From 9b193b9794bb6f332074244146703fc761461a43 Mon Sep 17 00:00:00 2001 From: VeljkoMaksimovic Date: Fri, 17 Oct 2025 10:16:50 +0200 Subject: [PATCH 01/10] Add rbac db schema --- ee/rbac/README.md | 317 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 ee/rbac/README.md diff --git a/ee/rbac/README.md b/ee/rbac/README.md new file mode 100644 index 000000000..83d8da536 --- /dev/null +++ b/ee/rbac/README.md @@ -0,0 +1,317 @@ +# RBAC Service + +Role-Based Access Control (RBAC) service for Semaphore CI/CD platform. + +## Database Schema + +### Core RBAC System + +```mermaid +erDiagram + scopes ||--o{ permissions : has + scopes ||--o{ rbac_roles : has + + permissions ||--o{ role_permission_bindings : "" + rbac_roles ||--o{ role_permission_bindings : "" + + rbac_roles ||--o{ role_inheritance : inheriting + rbac_roles ||--o{ role_inheritance : inherited + + rbac_roles ||--o{ subject_role_bindings : "" + subjects ||--o{ subject_role_bindings : "" + + scopes { + int id + string scope_name + } + + permissions { + int id + string name + int scope_id + string description + } + + rbac_roles { + int id + string name + uuid org_id + int scope_id + string description + boolean editable + } + + role_permission_bindings { + int permission_id + int rbac_role_id + } + + role_inheritance { + int inheriting_role_id + int inherited_role_id + } + + subject_role_bindings { + int id + int role_id + uuid org_id + uuid project_id + int subject_id + string binding_source + } + + subjects { + int id + string name + string type + } + + rbac_roles ||--o{ org_role_to_proj_role_mappings : org_role + rbac_roles ||--o{ org_role_to_proj_role_mappings : proj_role + + org_role_to_proj_role_mappings { + int org_role_id + int proj_role_id + } +``` + +### Subject System (Users & Groups) + +```mermaid +erDiagram + subjects ||--o| rbac_users : is_type + subjects ||--o| groups : is_type + + rbac_users ||--o{ user_group_bindings : "" + groups ||--o{ user_group_bindings : "" + + subjects { + int id + string name + string type + } + + rbac_users { + int id + string email + string name + } + + groups { + int id + uuid org_id + uuid creator_id + string description + } + + user_group_bindings { + int user_id + int group_id + } +``` + +### Identity Provider Integration + +```mermaid +erDiagram + rbac_users ||--o{ oidc_sessions : "" + rbac_users ||--o{ oidc_users : "" + + rbac_users { + int id + string email + string name + } + + oidc_sessions { + uuid id + int user_id + bytea refresh_token_enc + bytea id_token_enc + timestamp expires_at + } + + oidc_users { + uuid id + int user_id + string oidc_user_id + } + + okta_integrations { + int id + uuid org_id + uuid creator_id + string saml_issuer + boolean jit_provisioning_enabled + } + + okta_users { + int id + uuid integration_id + uuid org_id + uuid user_id + string email + } + + saml_jit_users { + int id + uuid integration_id + uuid org_id + uuid user_id + string email + } + + idp_group_mapping { + int id + uuid organization_id + uuid default_role_id + array role_mapping + array group_mapping + } +``` + +### Legacy Tables + +```mermaid +erDiagram + projects ||--o{ collaborators : "" + projects ||--o{ project_members : "" + users ||--o{ project_members : "" + users ||--o{ roles : "" + + projects { + uuid id + uuid project_id + string repo_name + uuid org_id + string provider + } + + collaborators { + uuid id + uuid project_id + string github_username + string github_uid + boolean admin + boolean push + boolean pull + } + + users { + uuid id + uuid user_id + string github_uid + string provider + } + + project_members { + uuid id + uuid project_id + uuid user_id + } + + roles { + uuid id + uuid user_id + uuid org_id + string name + } +``` + +### Key-Value Stores & Audit + +```mermaid +erDiagram + user_permissions_key_value_store { + string key + text value + } + + project_access_key_value_store { + string key + text value + } + + global_permissions_audit_log { + int id + string key + text old_value + text new_value + string query_operation + boolean notified + } +``` + +### Background Job Tables + +```mermaid +erDiagram + collaborator_refresh_requests { + uuid id + uuid org_id + string state + uuid requester_user_id + } + + rbac_refresh_project_access_requests { + uuid id + string state + uuid org_id + int user_id + } + + rbac_refresh_all_permissions_requests { + int id + string state + int organizations_updated + int retries + } + + group_management_request { + int id + string state + uuid user_id + uuid group_id + string action + } +``` + +## Schema Notes + +### RBAC Architecture +- **Scopes** categorize permissions (org-level, project-level) +- **Permissions** are individual access rights within scopes +- **Roles** bundle multiple permissions together +- **Role Inheritance** allows roles to inherit permissions from other roles +- **Org-to-Project Mappings** automatically map organization roles to project roles + +### Subject System (Polymorphic Design) +- **Subjects** is a base table for both users and groups +- **rbac_users** inherits from subjects (1:1 relationship) +- **groups** inherits from subjects (1:1 relationship) +- **subject_role_bindings** assigns roles to any subject with source tracking + +### Binding Sources +The `subject_role_bindings.binding_source` enum tracks where role assignments originate: +- `github` - From GitHub collaborator permissions +- `bitbucket` - From Bitbucket collaborator permissions +- `gitlab` - From GitLab collaborator permissions +- `manually_assigned` - Manually assigned by admin +- `okta` - From Okta/SCIM integration +- `inherited_from_org_role` - Inherited from organization role +- `saml_jit` - From SAML just-in-time provisioning + +### Repository Access Mapping +- Maps legacy repository permissions (admin/push/pull) to RBAC roles +- One mapping per organization +- References three different roles for each access level + +### Identity Providers +- **OIDC**: OpenID Connect sessions and user mappings +- **Okta**: SAML/SCIM integration with JIT provisioning support +- **SAML JIT**: Just-in-time user provisioning via SAML + +### Audit System +- Database trigger on `user_permissions_key_value_store` automatically logs changes +- Tracks old/new values for permission changes +- Notified flag for tracking alert status From 848ea56b39a3d7d288f62fc512d5b245edc8f533 Mon Sep 17 00:00:00 2001 From: VeljkoMaksimovic Date: Fri, 17 Oct 2025 11:23:55 +0200 Subject: [PATCH 02/10] Add rbac explanation --- ee/rbac/README.md | 182 +++++++++++++++++++++------------------------- 1 file changed, 83 insertions(+), 99 deletions(-) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index 83d8da536..44d618071 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -75,6 +75,10 @@ erDiagram } ``` +The center of the RBAC system is the subject role bindings table, which assigns a role to a given subject (either a user or a group), and that role has a list of permissions attached to it. + +There are two resources to which roles can be assigned: you can have a role within the organization, or you can have a role within the project. If you want to assign a role within the organization, that role has to be of the "organization scope", and if you want to assign a role within the project, then the role you are assigning must be of the "project scope". + ### Subject System (Users & Groups) ```mermaid @@ -110,6 +114,48 @@ erDiagram } ``` +### Additional Complexity + +Role Inheritance + +One role can inherit another role and all of its permissions. Every time we want to calculate the permissions you have, we have to check the roles you are assigned, but also all the roles they inherit. This is a redundant feature. We're not really using in our production setup (except some Insider roles). Even though this is tested and works, we've never really found a use for it. When you're trying to create a new role within the custom roles UI, there is no way for you to set up role inheritance. + +Organization Role to Project Role Mappings + +Another table is organization role to project role mappings, which also makes this a bit more complex. This is something we are using regularly. You can say that some organizational role, like "Owner", carries automatic "Admin" access to all of the projects within the organization. In this case, organization role "Owner" maps to project role "Admin", and this also has to be taken into consideration when we are checking if user has access to a project: Even though they might not have a role directly within the project, they maybe have an organization role which maps to project role. + +Groups + +The subject in subject role bindings can be a user, but it can also be a group. When we are actually trying to see which permissions a user has, we have to track all of the roles assigned directly. We also have to check if the user is part of a group, and then if they are, we also need to check all of the roles that the group has. +We have tested groups thoroughly, but I'm not sure if any customers are using them. + +### Key-Value Stores & Audit + +```mermaid +erDiagram + user_permissions_key_value_store { + string key + text value + } + + project_access_key_value_store { + string key + text value + } +``` + +User Permission Key Value Store + +All of this complexity makes actually figuring out which permissions a user has within an organization (or project) a bit more complex. It's not as simple as just tracking the subject role bindings table. It takes quite a few joins, and some recursive joins. Query which calculates all of the permisions for a given user/organization/project is written in the `Rbac.ComputePermissions` module of rhis service. Depending on the size of the organization, number of user and projects they have, it can take from >1s, to 6,7s to calculate these permission. + +That's why we had a need for `user_permissions_key_value_store` and `project_access_key_value_store`. Instead of calculating all of the permissions for every "GET" query, there is one table which stores all of the permissions user has within the org and/or project, and another with list of projects user has access to within the organization. + +These key value stores are recalculated anytime somebody is assigned a new role, anytime somebody's role is being removed, when you are joining a group, when you are being removed from a group, or when the role definition changes (which can happen with custom roles). + +Performance Issues + +As mentioned above, recalculation permissions usually takes around a second, but for some organizations that have a lot of projects, it can take five or six seconds. In some extreme cases, it can take around 10+ seconds, and this is where a problem occurs because we are hitting gRPC request timeout. You get bad UX experience when you want to change a role and you get a spinner for, let's say, 10 seconds, and it just times out. One major improvement we can do is to make role assignment and role retraction asynchronous, like many other operations in RBAC already are. + ### Identity Provider Integration ```mermaid @@ -170,8 +216,44 @@ erDiagram } ``` +### Background Job Tables + +```mermaid +erDiagram + collaborator_refresh_requests { + uuid id + uuid org_id + string state + uuid requester_user_id + } + + rbac_refresh_project_access_requests { + uuid id + string state + uuid org_id + int user_id + } + + rbac_refresh_all_permissions_requests { + int id + string state + int organizations_updated + int retries + } + + group_management_request { + int id + string state + uuid user_id + uuid group_id + string action + } +``` + ### Legacy Tables +These tables are leftover from the old auth system. We still use collaborators table when we want to sych GitHub repo access with the Semaphore project roles. + ```mermaid erDiagram projects ||--o{ collaborators : "" @@ -216,102 +298,4 @@ erDiagram uuid org_id string name } -``` - -### Key-Value Stores & Audit - -```mermaid -erDiagram - user_permissions_key_value_store { - string key - text value - } - - project_access_key_value_store { - string key - text value - } - - global_permissions_audit_log { - int id - string key - text old_value - text new_value - string query_operation - boolean notified - } -``` - -### Background Job Tables - -```mermaid -erDiagram - collaborator_refresh_requests { - uuid id - uuid org_id - string state - uuid requester_user_id - } - - rbac_refresh_project_access_requests { - uuid id - string state - uuid org_id - int user_id - } - - rbac_refresh_all_permissions_requests { - int id - string state - int organizations_updated - int retries - } - - group_management_request { - int id - string state - uuid user_id - uuid group_id - string action - } -``` - -## Schema Notes - -### RBAC Architecture -- **Scopes** categorize permissions (org-level, project-level) -- **Permissions** are individual access rights within scopes -- **Roles** bundle multiple permissions together -- **Role Inheritance** allows roles to inherit permissions from other roles -- **Org-to-Project Mappings** automatically map organization roles to project roles - -### Subject System (Polymorphic Design) -- **Subjects** is a base table for both users and groups -- **rbac_users** inherits from subjects (1:1 relationship) -- **groups** inherits from subjects (1:1 relationship) -- **subject_role_bindings** assigns roles to any subject with source tracking - -### Binding Sources -The `subject_role_bindings.binding_source` enum tracks where role assignments originate: -- `github` - From GitHub collaborator permissions -- `bitbucket` - From Bitbucket collaborator permissions -- `gitlab` - From GitLab collaborator permissions -- `manually_assigned` - Manually assigned by admin -- `okta` - From Okta/SCIM integration -- `inherited_from_org_role` - Inherited from organization role -- `saml_jit` - From SAML just-in-time provisioning - -### Repository Access Mapping -- Maps legacy repository permissions (admin/push/pull) to RBAC roles -- One mapping per organization -- References three different roles for each access level - -### Identity Providers -- **OIDC**: OpenID Connect sessions and user mappings -- **Okta**: SAML/SCIM integration with JIT provisioning support -- **SAML JIT**: Just-in-time user provisioning via SAML - -### Audit System -- Database trigger on `user_permissions_key_value_store` automatically logs changes -- Tracks old/new values for permission changes -- Notified flag for tracking alert status +``` \ No newline at end of file From 3f8fa357d35e9f59ef1adcefa4a520ca45b1d892 Mon Sep 17 00:00:00 2001 From: Veljko Maksimovic <45179708+VeljkoMaksimovic@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:24:11 +0100 Subject: [PATCH 03/10] Update README.md --- ee/rbac/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index 44d618071..2aaad10cd 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -122,7 +122,7 @@ One role can inherit another role and all of its permissions. Every time we want Organization Role to Project Role Mappings -Another table is organization role to project role mappings, which also makes this a bit more complex. This is something we are using regularly. You can say that some organizational role, like "Owner", carries automatic "Admin" access to all of the projects within the organization. In this case, organization role "Owner" maps to project role "Admin", and this also has to be taken into consideration when we are checking if user has access to a project: Even though they might not have a role directly within the project, they maybe have an organization role which maps to project role. +Another table is organization role to project role mappings. This is something we are using regularly. You can say that some organizational role, like "Owner", carries automatic "Admin" access to all of the projects within the organization. In this case, organization role "Owner" maps to project role "Admin", and this also has to be taken into consideration when we are checking if user has access to a project: Even though they might not have a role directly within the project, they maybe have an organization role which maps to project role. Groups @@ -146,7 +146,7 @@ erDiagram User Permission Key Value Store -All of this complexity makes actually figuring out which permissions a user has within an organization (or project) a bit more complex. It's not as simple as just tracking the subject role bindings table. It takes quite a few joins, and some recursive joins. Query which calculates all of the permisions for a given user/organization/project is written in the `Rbac.ComputePermissions` module of rhis service. Depending on the size of the organization, number of user and projects they have, it can take from >1s, to 6,7s to calculate these permission. +All of this complexity makes actually figuring out which permissions a user has within an organization (or project) a bit more time consuming. Query which calculates all of the permisions for a given user/organization/project is written in the `Rbac.ComputePermissions` module of this service. Depending on the size of the organization, number of users and projects they have, it can take from >1s, to 6,7s to calculate these permission. That's why we had a need for `user_permissions_key_value_store` and `project_access_key_value_store`. Instead of calculating all of the permissions for every "GET" query, there is one table which stores all of the permissions user has within the org and/or project, and another with list of projects user has access to within the organization. @@ -298,4 +298,4 @@ erDiagram uuid org_id string name } -``` \ No newline at end of file +``` From 2d43424c0b0bcba8d3f73c984067b57e9967fa29 Mon Sep 17 00:00:00 2001 From: LoadeZ Date: Mon, 27 Oct 2025 08:54:13 -0300 Subject: [PATCH 04/10] feat(front): add members download button (#664) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 Description - Add members download button on people page - Implement export members as csv endpoint - Add export members test ## ✅ Checklist - [x] I have tested this change - [ ] This change requires documentation update Signed-off-by: Veljko Maksimovic --- .../components/ServiceAccountsList.tsx | 26 +++++--- front/assets/js/service_accounts/config.ts | 1 + front/assets/js/service_accounts/types.ts | 1 + .../controllers/people_controller.ex | 63 ++++++++++++++++++- .../controllers/service_account_controller.ex | 51 +++++++++++++++ front/lib/front_web/router.ex | 2 + .../people/members/members_list.html.eex | 10 ++- .../controllers/people_controller_test.exs | 40 ++++++++++++ .../service_account_controller_test.exs | 59 +++++++++++++++++ 9 files changed, 241 insertions(+), 12 deletions(-) diff --git a/front/assets/js/service_accounts/components/ServiceAccountsList.tsx b/front/assets/js/service_accounts/components/ServiceAccountsList.tsx index 411e864e5..48a811ca6 100644 --- a/front/assets/js/service_accounts/components/ServiceAccountsList.tsx +++ b/front/assets/js/service_accounts/components/ServiceAccountsList.tsx @@ -72,15 +72,23 @@ export const ServiceAccountsList = ({
Service Accounts
- {config.permissions.canManage && ( - - )} +
+ {config.isOrgScope && ( + + download + Download .csv + + )} + {config.permissions.canManage && ( + + )} +
{serviceAccounts.length === 0 ? ( diff --git a/front/assets/js/service_accounts/config.ts b/front/assets/js/service_accounts/config.ts index fac35882e..e62b28639 100644 --- a/front/assets/js/service_accounts/config.ts +++ b/front/assets/js/service_accounts/config.ts @@ -19,6 +19,7 @@ export class AppConfig { urls: { list: `/service_accounts`, create: `/service_accounts`, + export: `/service_accounts/export`, update: (id: string) => `/service_accounts/${id}`, delete: (id: string) => `/service_accounts/${id}`, regenerateToken: (id: string) => diff --git a/front/assets/js/service_accounts/types.ts b/front/assets/js/service_accounts/types.ts index 9be8ed049..dca203df2 100644 --- a/front/assets/js/service_accounts/types.ts +++ b/front/assets/js/service_accounts/types.ts @@ -59,6 +59,7 @@ export interface Config { urls: { list: string; create: string; + export: string; update: (id: string) => string; delete: (id: string) => string; regenerateToken: (id: string) => string; diff --git a/front/lib/front_web/controllers/people_controller.ex b/front/lib/front_web/controllers/people_controller.ex index 9067556b3..e240c80e0 100644 --- a/front/lib/front_web/controllers/people_controller.ex +++ b/front/lib/front_web/controllers/people_controller.ex @@ -24,8 +24,12 @@ defmodule FrontWeb.PeopleController do plug(FetchPermissions, [scope: "org"] when action in @person_action) plug(PageAccess, [permissions: "organization.view"] when action in @person_action) - plug(FetchPermissions, [scope: "org"] when action in [:organization]) - plug(PageAccess, [permissions: "organization.view"] when action in [:organization]) + plug(FetchPermissions, [scope: "org"] when action in [:organization, :organization_users]) + + plug( + PageAccess, + [permissions: "organization.view"] when action in [:organization, :organization_users] + ) plug( FetchPermissions, @@ -1061,6 +1065,51 @@ defmodule FrontWeb.PeopleController do end) end + def organization_users(conn, _params) do + Watchman.benchmark("people.organization_users.duration", fn -> + org_id = conn.assigns.organization_id + + page_size = 100 + + data = + Stream.unfold(0, fn + nil -> + nil + + page_no -> + case Members.list_org_members(org_id, page_no: page_no, page_size: page_size) do + {:ok, {members, total_pages}} -> + {members, next_valid_page_or_nil(total_pages, page_no)} + + _ -> + nil + end + end) + |> Enum.flat_map(& &1) + |> Enum.map(fn e -> + %{ + "name" => e.name, + "email" => e.email, + "github_login" => e.github_login, + "bitbucket_login" => e.bitbucket_login, + "gitlab_login" => e.gitlab_login + } + end) + |> CSV.encode( + headers: [ + "name", + "email", + "github_login", + "bitbucket_login", + "gitlab_login" + ] + ) + |> Enum.to_list() + + send_download(conn, {:binary, data}, filename: "users.csv") + end) + end + def sync(conn, %{"format" => "json"}) do Watchman.benchmark("sync.organization.duration", fn -> org_id = conn.assigns.organization_id @@ -1213,4 +1262,14 @@ defmodule FrontWeb.PeopleController do email_regex = ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/ Regex.match?(email_regex, email) end + + defp next_valid_page_or_nil(total_pages, page) do + next_page_no = page + 1 + + if next_page_no <= total_pages do + next_page_no + else + nil + end + end end diff --git a/front/lib/front_web/controllers/service_account_controller.ex b/front/lib/front_web/controllers/service_account_controller.ex index 736d6e44f..19e555b4f 100644 --- a/front/lib/front_web/controllers/service_account_controller.ex +++ b/front/lib/front_web/controllers/service_account_controller.ex @@ -138,4 +138,55 @@ defmodule FrontWeb.ServiceAccountController do |> json(%{error: message}) end end + + def export(conn, _params) do + org_id = conn.assigns.organization_id + + data = + Stream.unfold(1, fn + nil -> + nil + + page -> + case Models.ServiceAccount.list(org_id, page) do + {:ok, {service_accounts, total_pages}} -> + {service_accounts, next_valid_page_or_nil(total_pages, page)} + + _ -> + nil + end + end) + |> Enum.flat_map(& &1) + |> Enum.map(fn e -> + %{ + "name" => e.name, + "description" => e.description, + "deactivated" => e.deactivated, + "created_at" => e.created_at, + "updated_at" => e.created_at + } + end) + |> CSV.encode( + headers: [ + "name", + "description", + "deactivated", + "created_at", + "updated_at" + ] + ) + |> Enum.to_list() + + send_download(conn, {:binary, data}, filename: "service_accounts.csv") + end + + defp next_valid_page_or_nil(total_pages, page) do + next_page_no = page + 1 + + if next_page_no <= total_pages do + next_page_no + else + nil + end + end end diff --git a/front/lib/front_web/router.ex b/front/lib/front_web/router.ex index 6327c64b0..caf202a58 100644 --- a/front/lib/front_web/router.ex +++ b/front/lib/front_web/router.ex @@ -152,6 +152,7 @@ defmodule FrontWeb.Router do scope "/people" do get("/", PeopleController, :organization) + get("/export", PeopleController, :organization_users) post("/", PeopleController, :create) post("/refresh", PeopleController, :refresh) post("/assign_role", PeopleController, :assign_role) @@ -175,6 +176,7 @@ defmodule FrontWeb.Router do scope "/service_accounts" do get("/", ServiceAccountController, :index) post("/", ServiceAccountController, :create) + get("/export", ServiceAccountController, :export) get("/:id", ServiceAccountController, :show) put("/:id", ServiceAccountController, :update) delete("/:id", ServiceAccountController, :delete) diff --git a/front/lib/front_web/templates/people/members/members_list.html.eex b/front/lib/front_web/templates/people/members/members_list.html.eex index 0064503de..a60d54e11 100644 --- a/front/lib/front_web/templates/people/members/members_list.html.eex +++ b/front/lib/front_web/templates/people/members/members_list.html.eex @@ -32,7 +32,15 @@
People
- <%= render "members/_add_people_button.html", conn: @conn, org_scope?: @org_scope?, permissions: @permissions %> +
+ <%= if @org_scope? do %> + <%= link to: people_path(@conn, :organization_users), class: "pointer flex items-center btn-secondary btn nowrap mr2", aria_label: "Download members as CSV", title: "Download members as CSV" do %> + download + Download .csv + <% end %> + <% end %> + <%= render "members/_add_people_button.html", conn: @conn, org_scope?: @org_scope?, permissions: @permissions %> +
diff --git a/front/test/front_web/controllers/people_controller_test.exs b/front/test/front_web/controllers/people_controller_test.exs index bbba532fe..4c6c6d101 100644 --- a/front/test/front_web/controllers/people_controller_test.exs +++ b/front/test/front_web/controllers/people_controller_test.exs @@ -36,6 +36,46 @@ defmodule FrontWeb.PeopleControllerTest do ] end + describe "GET organization_users" do + test "when the user can't access the org => returns 404", %{ + conn: conn + } do + PermissionPatrol.remove_all_permissions() + + conn = + conn + |> get("/people/export") + + assert html_response(conn, 404) =~ "404" + end + + test "when the user can access the org => send csv", %{ + conn: conn + } do + conn = + conn + |> get("/people/export") + + assert response_content_type(conn, :csv) + + rows = + conn.resp_body + |> String.split("\r\n", trim: true) + |> CSV.decode!(validate_row_length: true, headers: true) + |> Enum.to_list() + + assert length(rows) == 8 + + first = List.first(rows) + + assert Map.has_key?(first, "name") + assert Map.has_key?(first, "email") + assert Map.has_key?(first, "github_login") + assert Map.has_key?(first, "bitbucket_login") + assert Map.has_key?(first, "gitlab_login") + end + end + describe "GET show" do test "when the user can't access the org => returns 404", %{ conn: conn, diff --git a/front/test/front_web/controllers/service_account_controller_test.exs b/front/test/front_web/controllers/service_account_controller_test.exs index 5b32c1c4a..cecc7aef8 100644 --- a/front/test/front_web/controllers/service_account_controller_test.exs +++ b/front/test/front_web/controllers/service_account_controller_test.exs @@ -268,6 +268,65 @@ defmodule FrontWeb.ServiceAccountControllerTest do end end + describe "GET /service_accounts/export" do + test "requires service_accounts.view permission", %{ + conn: conn, + org_id: org_id, + user_id: user_id + } do + Support.Stubs.PermissionPatrol.remove_all_permissions() + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, ["organization.view"]) + + conn = get(conn, "/service_accounts/export") + + assert html_response(conn, 404) =~ "Page not found" + end + + test "when the user can access the org => send csv", %{ + conn: conn, + org_id: org_id, + user_id: user_id + } do + Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, ["organization.view"]) + + expect(ServiceAccountMock, :describe_many, fn members -> + service_accounts = + Enum.map(members, fn member_id -> + %InternalApi.ServiceAccount.ServiceAccount{ + id: member_id, + name: "Test Service Account", + description: "Test description", + org_id: org_id, + creator_id: "", + created_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200}, + updated_at: %Google.Protobuf.Timestamp{seconds: 1_704_103_200}, + deactivated: false + } + end) + + {:ok, service_accounts} + end) + + conn = get(conn, "/service_accounts/export") + + rows = + conn.resp_body + |> String.split("\r\n", trim: true) + |> CSV.decode!(validate_row_length: true, headers: true) + |> Enum.to_list() + + assert length(rows) == 3 + + first = List.first(rows) + + assert Map.has_key?(first, "name") + assert Map.has_key?(first, "description") + assert Map.has_key?(first, "deactivated") + assert Map.has_key?(first, "created_at") + assert Map.has_key?(first, "updated_at") + end + end + describe "PUT /service_accounts/:id" do setup %{org_id: org_id, user_id: user_id} do Support.Stubs.PermissionPatrol.add_permissions(org_id, user_id, [ From 80d3752f42e7b5bf148a730caa1cd3ce9e4cf1b5 Mon Sep 17 00:00:00 2001 From: Veljko Maksimovic Date: Mon, 27 Oct 2025 15:30:26 +0100 Subject: [PATCH 05/10] Add explanation for saml/scim provisioning Signed-off-by: Veljko Maksimovic --- ee/rbac/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index 2aaad10cd..ccf20e9d9 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -299,3 +299,24 @@ erDiagram string name } ``` + +## Provisioning SCIM/SAML JIT Users + +### SCIM Provisioning + +When we receive a SCIM request, we first validate that the signature is correct. Once validated, we create an `okta_user` entity in the database (note: this is an outdated name that predates our support for multiple identity providers), or update an existing `okta_user` if one already exists. The user is placed in a pending state. + +A SCIM provisioner async worker then processes these pending `okta_user` entities. Based on the payload of the SCIM request (which is stored as part of the `okta_user` database entity), the worker performs one of three actions: +- Creates a new user and adds them to the organization +- Updates an existing user +- Deactivates a user and removes them from the organization + +### SAML JIT Provisioning + +When we receive a SAML request, we check if an `okta_user` (SCIM user) already exists for that user. If no SCIM user exists, we return a 404 error, unless the organization has SAML JIT (Just-In-Time) provisioning enabled. + +If SAML JIT provisioning is enabled and this is the user's first SAML request, we create a `saml_jit_user` entity. The SAML JIT provisioner picks this up immediately and creates a proper Semaphore user. + +### Known Limitation + +The SAML JIT provisioning implementation has a limitation that should be addressed. Even though the structure of the `saml_jit_user` entity is designed to work with async workers, those workers were never implemented. The current implementation processes SAML JIT users synchronously during the request. If there is no available database connection at the time of the SAML request, the request will fail, and the SAML JIT user will not be processed again. This differs from the SCIM provisioning flow, which properly handles retries through the async worker pattern. From 1c40a280bca7d3d4eab41dd44e7b1b0d13efb91f Mon Sep 17 00:00:00 2001 From: VeljkoMaksimovic Date: Fri, 17 Oct 2025 10:16:50 +0200 Subject: [PATCH 06/10] Add rbac db schema --- ee/rbac/README.md | 317 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 ee/rbac/README.md diff --git a/ee/rbac/README.md b/ee/rbac/README.md new file mode 100644 index 000000000..83d8da536 --- /dev/null +++ b/ee/rbac/README.md @@ -0,0 +1,317 @@ +# RBAC Service + +Role-Based Access Control (RBAC) service for Semaphore CI/CD platform. + +## Database Schema + +### Core RBAC System + +```mermaid +erDiagram + scopes ||--o{ permissions : has + scopes ||--o{ rbac_roles : has + + permissions ||--o{ role_permission_bindings : "" + rbac_roles ||--o{ role_permission_bindings : "" + + rbac_roles ||--o{ role_inheritance : inheriting + rbac_roles ||--o{ role_inheritance : inherited + + rbac_roles ||--o{ subject_role_bindings : "" + subjects ||--o{ subject_role_bindings : "" + + scopes { + int id + string scope_name + } + + permissions { + int id + string name + int scope_id + string description + } + + rbac_roles { + int id + string name + uuid org_id + int scope_id + string description + boolean editable + } + + role_permission_bindings { + int permission_id + int rbac_role_id + } + + role_inheritance { + int inheriting_role_id + int inherited_role_id + } + + subject_role_bindings { + int id + int role_id + uuid org_id + uuid project_id + int subject_id + string binding_source + } + + subjects { + int id + string name + string type + } + + rbac_roles ||--o{ org_role_to_proj_role_mappings : org_role + rbac_roles ||--o{ org_role_to_proj_role_mappings : proj_role + + org_role_to_proj_role_mappings { + int org_role_id + int proj_role_id + } +``` + +### Subject System (Users & Groups) + +```mermaid +erDiagram + subjects ||--o| rbac_users : is_type + subjects ||--o| groups : is_type + + rbac_users ||--o{ user_group_bindings : "" + groups ||--o{ user_group_bindings : "" + + subjects { + int id + string name + string type + } + + rbac_users { + int id + string email + string name + } + + groups { + int id + uuid org_id + uuid creator_id + string description + } + + user_group_bindings { + int user_id + int group_id + } +``` + +### Identity Provider Integration + +```mermaid +erDiagram + rbac_users ||--o{ oidc_sessions : "" + rbac_users ||--o{ oidc_users : "" + + rbac_users { + int id + string email + string name + } + + oidc_sessions { + uuid id + int user_id + bytea refresh_token_enc + bytea id_token_enc + timestamp expires_at + } + + oidc_users { + uuid id + int user_id + string oidc_user_id + } + + okta_integrations { + int id + uuid org_id + uuid creator_id + string saml_issuer + boolean jit_provisioning_enabled + } + + okta_users { + int id + uuid integration_id + uuid org_id + uuid user_id + string email + } + + saml_jit_users { + int id + uuid integration_id + uuid org_id + uuid user_id + string email + } + + idp_group_mapping { + int id + uuid organization_id + uuid default_role_id + array role_mapping + array group_mapping + } +``` + +### Legacy Tables + +```mermaid +erDiagram + projects ||--o{ collaborators : "" + projects ||--o{ project_members : "" + users ||--o{ project_members : "" + users ||--o{ roles : "" + + projects { + uuid id + uuid project_id + string repo_name + uuid org_id + string provider + } + + collaborators { + uuid id + uuid project_id + string github_username + string github_uid + boolean admin + boolean push + boolean pull + } + + users { + uuid id + uuid user_id + string github_uid + string provider + } + + project_members { + uuid id + uuid project_id + uuid user_id + } + + roles { + uuid id + uuid user_id + uuid org_id + string name + } +``` + +### Key-Value Stores & Audit + +```mermaid +erDiagram + user_permissions_key_value_store { + string key + text value + } + + project_access_key_value_store { + string key + text value + } + + global_permissions_audit_log { + int id + string key + text old_value + text new_value + string query_operation + boolean notified + } +``` + +### Background Job Tables + +```mermaid +erDiagram + collaborator_refresh_requests { + uuid id + uuid org_id + string state + uuid requester_user_id + } + + rbac_refresh_project_access_requests { + uuid id + string state + uuid org_id + int user_id + } + + rbac_refresh_all_permissions_requests { + int id + string state + int organizations_updated + int retries + } + + group_management_request { + int id + string state + uuid user_id + uuid group_id + string action + } +``` + +## Schema Notes + +### RBAC Architecture +- **Scopes** categorize permissions (org-level, project-level) +- **Permissions** are individual access rights within scopes +- **Roles** bundle multiple permissions together +- **Role Inheritance** allows roles to inherit permissions from other roles +- **Org-to-Project Mappings** automatically map organization roles to project roles + +### Subject System (Polymorphic Design) +- **Subjects** is a base table for both users and groups +- **rbac_users** inherits from subjects (1:1 relationship) +- **groups** inherits from subjects (1:1 relationship) +- **subject_role_bindings** assigns roles to any subject with source tracking + +### Binding Sources +The `subject_role_bindings.binding_source` enum tracks where role assignments originate: +- `github` - From GitHub collaborator permissions +- `bitbucket` - From Bitbucket collaborator permissions +- `gitlab` - From GitLab collaborator permissions +- `manually_assigned` - Manually assigned by admin +- `okta` - From Okta/SCIM integration +- `inherited_from_org_role` - Inherited from organization role +- `saml_jit` - From SAML just-in-time provisioning + +### Repository Access Mapping +- Maps legacy repository permissions (admin/push/pull) to RBAC roles +- One mapping per organization +- References three different roles for each access level + +### Identity Providers +- **OIDC**: OpenID Connect sessions and user mappings +- **Okta**: SAML/SCIM integration with JIT provisioning support +- **SAML JIT**: Just-in-time user provisioning via SAML + +### Audit System +- Database trigger on `user_permissions_key_value_store` automatically logs changes +- Tracks old/new values for permission changes +- Notified flag for tracking alert status From ca97f6a4a36409bec70f6ee148480818decd6bc5 Mon Sep 17 00:00:00 2001 From: VeljkoMaksimovic Date: Fri, 17 Oct 2025 11:23:55 +0200 Subject: [PATCH 07/10] Add rbac explanation --- ee/rbac/README.md | 182 +++++++++++++++++++++------------------------- 1 file changed, 83 insertions(+), 99 deletions(-) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index 83d8da536..44d618071 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -75,6 +75,10 @@ erDiagram } ``` +The center of the RBAC system is the subject role bindings table, which assigns a role to a given subject (either a user or a group), and that role has a list of permissions attached to it. + +There are two resources to which roles can be assigned: you can have a role within the organization, or you can have a role within the project. If you want to assign a role within the organization, that role has to be of the "organization scope", and if you want to assign a role within the project, then the role you are assigning must be of the "project scope". + ### Subject System (Users & Groups) ```mermaid @@ -110,6 +114,48 @@ erDiagram } ``` +### Additional Complexity + +Role Inheritance + +One role can inherit another role and all of its permissions. Every time we want to calculate the permissions you have, we have to check the roles you are assigned, but also all the roles they inherit. This is a redundant feature. We're not really using in our production setup (except some Insider roles). Even though this is tested and works, we've never really found a use for it. When you're trying to create a new role within the custom roles UI, there is no way for you to set up role inheritance. + +Organization Role to Project Role Mappings + +Another table is organization role to project role mappings, which also makes this a bit more complex. This is something we are using regularly. You can say that some organizational role, like "Owner", carries automatic "Admin" access to all of the projects within the organization. In this case, organization role "Owner" maps to project role "Admin", and this also has to be taken into consideration when we are checking if user has access to a project: Even though they might not have a role directly within the project, they maybe have an organization role which maps to project role. + +Groups + +The subject in subject role bindings can be a user, but it can also be a group. When we are actually trying to see which permissions a user has, we have to track all of the roles assigned directly. We also have to check if the user is part of a group, and then if they are, we also need to check all of the roles that the group has. +We have tested groups thoroughly, but I'm not sure if any customers are using them. + +### Key-Value Stores & Audit + +```mermaid +erDiagram + user_permissions_key_value_store { + string key + text value + } + + project_access_key_value_store { + string key + text value + } +``` + +User Permission Key Value Store + +All of this complexity makes actually figuring out which permissions a user has within an organization (or project) a bit more complex. It's not as simple as just tracking the subject role bindings table. It takes quite a few joins, and some recursive joins. Query which calculates all of the permisions for a given user/organization/project is written in the `Rbac.ComputePermissions` module of rhis service. Depending on the size of the organization, number of user and projects they have, it can take from >1s, to 6,7s to calculate these permission. + +That's why we had a need for `user_permissions_key_value_store` and `project_access_key_value_store`. Instead of calculating all of the permissions for every "GET" query, there is one table which stores all of the permissions user has within the org and/or project, and another with list of projects user has access to within the organization. + +These key value stores are recalculated anytime somebody is assigned a new role, anytime somebody's role is being removed, when you are joining a group, when you are being removed from a group, or when the role definition changes (which can happen with custom roles). + +Performance Issues + +As mentioned above, recalculation permissions usually takes around a second, but for some organizations that have a lot of projects, it can take five or six seconds. In some extreme cases, it can take around 10+ seconds, and this is where a problem occurs because we are hitting gRPC request timeout. You get bad UX experience when you want to change a role and you get a spinner for, let's say, 10 seconds, and it just times out. One major improvement we can do is to make role assignment and role retraction asynchronous, like many other operations in RBAC already are. + ### Identity Provider Integration ```mermaid @@ -170,8 +216,44 @@ erDiagram } ``` +### Background Job Tables + +```mermaid +erDiagram + collaborator_refresh_requests { + uuid id + uuid org_id + string state + uuid requester_user_id + } + + rbac_refresh_project_access_requests { + uuid id + string state + uuid org_id + int user_id + } + + rbac_refresh_all_permissions_requests { + int id + string state + int organizations_updated + int retries + } + + group_management_request { + int id + string state + uuid user_id + uuid group_id + string action + } +``` + ### Legacy Tables +These tables are leftover from the old auth system. We still use collaborators table when we want to sych GitHub repo access with the Semaphore project roles. + ```mermaid erDiagram projects ||--o{ collaborators : "" @@ -216,102 +298,4 @@ erDiagram uuid org_id string name } -``` - -### Key-Value Stores & Audit - -```mermaid -erDiagram - user_permissions_key_value_store { - string key - text value - } - - project_access_key_value_store { - string key - text value - } - - global_permissions_audit_log { - int id - string key - text old_value - text new_value - string query_operation - boolean notified - } -``` - -### Background Job Tables - -```mermaid -erDiagram - collaborator_refresh_requests { - uuid id - uuid org_id - string state - uuid requester_user_id - } - - rbac_refresh_project_access_requests { - uuid id - string state - uuid org_id - int user_id - } - - rbac_refresh_all_permissions_requests { - int id - string state - int organizations_updated - int retries - } - - group_management_request { - int id - string state - uuid user_id - uuid group_id - string action - } -``` - -## Schema Notes - -### RBAC Architecture -- **Scopes** categorize permissions (org-level, project-level) -- **Permissions** are individual access rights within scopes -- **Roles** bundle multiple permissions together -- **Role Inheritance** allows roles to inherit permissions from other roles -- **Org-to-Project Mappings** automatically map organization roles to project roles - -### Subject System (Polymorphic Design) -- **Subjects** is a base table for both users and groups -- **rbac_users** inherits from subjects (1:1 relationship) -- **groups** inherits from subjects (1:1 relationship) -- **subject_role_bindings** assigns roles to any subject with source tracking - -### Binding Sources -The `subject_role_bindings.binding_source` enum tracks where role assignments originate: -- `github` - From GitHub collaborator permissions -- `bitbucket` - From Bitbucket collaborator permissions -- `gitlab` - From GitLab collaborator permissions -- `manually_assigned` - Manually assigned by admin -- `okta` - From Okta/SCIM integration -- `inherited_from_org_role` - Inherited from organization role -- `saml_jit` - From SAML just-in-time provisioning - -### Repository Access Mapping -- Maps legacy repository permissions (admin/push/pull) to RBAC roles -- One mapping per organization -- References three different roles for each access level - -### Identity Providers -- **OIDC**: OpenID Connect sessions and user mappings -- **Okta**: SAML/SCIM integration with JIT provisioning support -- **SAML JIT**: Just-in-time user provisioning via SAML - -### Audit System -- Database trigger on `user_permissions_key_value_store` automatically logs changes -- Tracks old/new values for permission changes -- Notified flag for tracking alert status +``` \ No newline at end of file From 71ddd94bdbd4910e389f09d5e123784c6c7e5cf0 Mon Sep 17 00:00:00 2001 From: Veljko Maksimovic <45179708+VeljkoMaksimovic@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:24:11 +0100 Subject: [PATCH 08/10] Update README.md --- ee/rbac/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index 44d618071..2aaad10cd 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -122,7 +122,7 @@ One role can inherit another role and all of its permissions. Every time we want Organization Role to Project Role Mappings -Another table is organization role to project role mappings, which also makes this a bit more complex. This is something we are using regularly. You can say that some organizational role, like "Owner", carries automatic "Admin" access to all of the projects within the organization. In this case, organization role "Owner" maps to project role "Admin", and this also has to be taken into consideration when we are checking if user has access to a project: Even though they might not have a role directly within the project, they maybe have an organization role which maps to project role. +Another table is organization role to project role mappings. This is something we are using regularly. You can say that some organizational role, like "Owner", carries automatic "Admin" access to all of the projects within the organization. In this case, organization role "Owner" maps to project role "Admin", and this also has to be taken into consideration when we are checking if user has access to a project: Even though they might not have a role directly within the project, they maybe have an organization role which maps to project role. Groups @@ -146,7 +146,7 @@ erDiagram User Permission Key Value Store -All of this complexity makes actually figuring out which permissions a user has within an organization (or project) a bit more complex. It's not as simple as just tracking the subject role bindings table. It takes quite a few joins, and some recursive joins. Query which calculates all of the permisions for a given user/organization/project is written in the `Rbac.ComputePermissions` module of rhis service. Depending on the size of the organization, number of user and projects they have, it can take from >1s, to 6,7s to calculate these permission. +All of this complexity makes actually figuring out which permissions a user has within an organization (or project) a bit more time consuming. Query which calculates all of the permisions for a given user/organization/project is written in the `Rbac.ComputePermissions` module of this service. Depending on the size of the organization, number of users and projects they have, it can take from >1s, to 6,7s to calculate these permission. That's why we had a need for `user_permissions_key_value_store` and `project_access_key_value_store`. Instead of calculating all of the permissions for every "GET" query, there is one table which stores all of the permissions user has within the org and/or project, and another with list of projects user has access to within the organization. @@ -298,4 +298,4 @@ erDiagram uuid org_id string name } -``` \ No newline at end of file +``` From 950d7939bb9f986672170c8951c3c4615dd9a342 Mon Sep 17 00:00:00 2001 From: Veljko Maksimovic Date: Mon, 27 Oct 2025 15:30:26 +0100 Subject: [PATCH 09/10] Add explanation for saml/scim provisioning Signed-off-by: Veljko Maksimovic --- ee/rbac/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index 2aaad10cd..ccf20e9d9 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -299,3 +299,24 @@ erDiagram string name } ``` + +## Provisioning SCIM/SAML JIT Users + +### SCIM Provisioning + +When we receive a SCIM request, we first validate that the signature is correct. Once validated, we create an `okta_user` entity in the database (note: this is an outdated name that predates our support for multiple identity providers), or update an existing `okta_user` if one already exists. The user is placed in a pending state. + +A SCIM provisioner async worker then processes these pending `okta_user` entities. Based on the payload of the SCIM request (which is stored as part of the `okta_user` database entity), the worker performs one of three actions: +- Creates a new user and adds them to the organization +- Updates an existing user +- Deactivates a user and removes them from the organization + +### SAML JIT Provisioning + +When we receive a SAML request, we check if an `okta_user` (SCIM user) already exists for that user. If no SCIM user exists, we return a 404 error, unless the organization has SAML JIT (Just-In-Time) provisioning enabled. + +If SAML JIT provisioning is enabled and this is the user's first SAML request, we create a `saml_jit_user` entity. The SAML JIT provisioner picks this up immediately and creates a proper Semaphore user. + +### Known Limitation + +The SAML JIT provisioning implementation has a limitation that should be addressed. Even though the structure of the `saml_jit_user` entity is designed to work with async workers, those workers were never implemented. The current implementation processes SAML JIT users synchronously during the request. If there is no available database connection at the time of the SAML request, the request will fail, and the SAML JIT user will not be processed again. This differs from the SCIM provisioning flow, which properly handles retries through the async worker pattern. From 8c0714a5de3c04256f2b790e11a69020a08963d1 Mon Sep 17 00:00:00 2001 From: Veljko Maksimovic Date: Mon, 27 Oct 2025 16:48:19 +0100 Subject: [PATCH 10/10] Add explanation for adding new roles --- ee/rbac/README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/ee/rbac/README.md b/ee/rbac/README.md index ccf20e9d9..e97331d19 100644 --- a/ee/rbac/README.md +++ b/ee/rbac/README.md @@ -4,6 +4,8 @@ Role-Based Access Control (RBAC) service for Semaphore CI/CD platform. ## Database Schema +Important note! Even though there is a dedicated RBAC microservice (this one), for legacy reasons it uses the Guard database. Therefore, if you need to make changes to the DB schema, you need to go to the Guard project and add an Ecto migration there. Then, copy that same migration here to RBAC for testing purposes. + ### Core RBAC System ```mermaid @@ -320,3 +322,22 @@ If SAML JIT provisioning is enabled and this is the user's first SAML request, w ### Known Limitation The SAML JIT provisioning implementation has a limitation that should be addressed. Even though the structure of the `saml_jit_user` entity is designed to work with async workers, those workers were never implemented. The current implementation processes SAML JIT users synchronously during the request. If there is no available database connection at the time of the SAML request, the request will fail, and the SAML JIT user will not be processed again. This differs from the SCIM provisioning flow, which properly handles retries through the async worker pattern. + +## Adding New Permissions/Changing Roles + +### Adding New Permissions + +When you want to add a new permission, there is a `permissions.yaml` file in the assets folder. You can add new permissions there, and on the next deployment, they will be added to the database. + +### Changing Roles + +Adding new permissions to existing roles (or changing roles at all) takes a few more steps. There is a `roles.yaml` file where all existing roles are listed, together with the permissions they have. These definitions apply to any newly created role. When a new organization is created, RBAC receives an AMQP message and creates all the roles for that new organization. These new roles will follow the role definitions from `roles.yaml`, but when you make changes to `roles.yaml`, existing roles won't be affected. + +If you want to change the existing roles, you have to: + +1. Change the contents of the `role_permission_binding` table +2. Recalculate all of the permissions in `user_permission_key_value_store` + +The second step can be (in theory) done just by executing the `recalculate_entire_cache` function within the `user_permissions` module, but if there is a lot of data, which is the case for our production environment, it will time out and fail. + +For that reason, we added a new worker called `refresh_all_permissions.ex`. By executing `Rbac.Repo.RbacRefreshAllPermissionsRequest.create_new_request()`, a new request will be inserted into the database, and the worker usually takes several hours to update all of the permissions in the cache for every single organization.