Introduce generic `namespace_consents` table and `Namespaces::Consent` model
## Summary Introduce a generic, reusable `namespace_consents` table that records explicit group-level consent to activate a named feature. This is the foundational infrastructure for epic [&22247](https://gitlab.com/groups/gitlab-org/-/work_items/22247) and is intentionally not tied to any specific workflow — future features can reuse the same table and model. --- ## Why The routing change in [&22247](https://gitlab.com/groups/gitlab-org/-/work_items/22247) must not silently switch existing Duo Enterprise groups to DAP-based code review. Groups that already have Code Review Flow enabled must explicitly re-confirm before the new routing takes effect. A `namespace_setting` boolean would record the state but offers no traceability: no timestamp, no actor, no audit trail. A dedicated consent table — modelled after `user_group_callouts` — records _which group_ consented, _which feature_ was consented to, _who_ triggered the consent, and _when_, making it auditable and extensible to future consent-gated features without schema changes. --- ## What ### 1. Migration Create the `namespace_consents` table: ```ruby create_table :namespace_consents do |t| # The namespace that was given consent. Cascade-delete consent when the namespace is deleted. t.references :namespace, null: false, foreign_key: { to_table: :namespaces, on_delete: :cascade }, index: false # The user who triggered the consent action. # Nullified via loose foreign key when the user is deleted — # the consent belongs to the namespace, not the user. t.bigint :user_id, null: true, index: true # Smallint enum — same convention as user_group_callouts — to keep the column compact # and avoid false-positive matches if values are ever removed. t.integer :feature_name, null: false, limit: 2 t.timestamps null: false # One consent record per namespace per feature. t.index [:namespace_id, :feature_name], unique: true, name: 'idx_namespace_consents_on_namespace_id_and_feature_name' end ``` **Cells / sharding key:** rows belong to a group (namespace), so the sharding key is `group_id → namespaces`. This matches the pattern used by `user_group_callouts`. **`user_id` FK:** because `users` lives in `gitlab_main_user` and `namespace_consents` will live in `gitlab_main_org`, a direct database-level FK on `user_id` would be a cross-schema FK. Use a **loose foreign key** (`async_nullify`) instead, consistent with how other `gitlab_main_org` tables handle `user_id` references. ### 2. Loose foreign key definition Add to `config/gitlab_loose_foreign_keys.yml`: ```yaml namespace_consents: - table: users column: user_id on_delete: async_nullify ``` ### 3. Database dictionary entry Add `db/docs/namespace_consents.yml`: ```yaml --- table_name: namespace_consents classes: - Namespaces::Consent feature_categories: - groups_and_projects description: > Records explicit group-level consent to activate a named feature. One row per group per feature_name. Stores the consenting user and timestamp for auditability. Reusable across features without schema changes. introduced_by_url: # filled in by MR milestone: '19.2' gitlab_schema: gitlab_main_org sharding_key: group_id: namespaces table_size: small ``` ### 4. Model: `Namespaces::Consent` ```ruby # ee/app/models/namespaces/consent.rb module Namespaces class Consent < ApplicationRecord self.table_name = 'namespace_consents' belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id # user_id is nullified asynchronously via loose foreign key on user deletion belongs_to :user, optional: true # NOTE: use new consecutive integer values for new features. # Do not reuse removed values to avoid false-positive consent matches. enum :feature_name, { code_review_flow_dap_routing: 1 # EE-only } validates :group, presence: true validates :feature_name, presence: true, uniqueness: { scope: :group_id }, inclusion: { in: Consent.feature_names.keys } end end ``` ### 5. Convenience query method on `Namespace` / `Group` ```ruby # ee/app/models/ee/namespace.rb (or a dedicated concern) def consented_to?(feature_name) Namespaces::Consent.where(group_id: id, feature_name: feature_name).exists? end ``` ### 6. Factory ```ruby # ee/spec/factories/groups/consents.rb FactoryBot.define do factory :group_consent, class: 'Namespaces::Consent' do association :group association :user feature_name { :code_review_flow_dap_routing } end end ``` --- ## How 1. Write the migration using `create_table` with the schema above. Use `add_concurrent_foreign_key` for the `group_id → namespaces` FK. 2. Add the loose foreign key entry for `user_id → users` in `config/gitlab_loose_foreign_keys.yml`. 3. Add `db/docs/namespace_consents.yml` with `gitlab_schema: gitlab_main_org` and `sharding_key: group_id: namespaces`. 4. Add `Namespaces::Consent` model under `ee/app/models/groups/`. 5. Add `consented_to?(feature_name)` to `EE::Namespace` (or a dedicated concern). 6. Add the factory under `ee/spec/factories/groups/`. 7. Write model specs covering: validations, enum uniqueness per group, `consented_to?` helper, and the loose foreign key behaviour (`it_behaves_like 'cleanup by a loose foreign key'`). --- ## Acceptance criteria - [ ] `namespace_consents` table exists with `group_id` (NOT NULL), `user_id` (nullable), `feature_name` (smallint, NOT NULL), `created_at`, `updated_at`. - [ ] Unique index on `(group_id, feature_name)`. - [ ] `group_id` FK to `namespaces` with `ON DELETE CASCADE`. - [ ] `user_id` handled via loose foreign key (`async_nullify`) — no direct DB-level FK to `users`. - [ ] `db/docs/namespace_consents.yml` present with `gitlab_schema: gitlab_main_org` and `sharding_key: group_id: namespaces`. - [ ] `Namespaces::Consent` model passes all validation and enum specs. - [ ] `consented_to?(feature_name)` helper available on `Namespace` / `Group`. - [ ] Loose foreign key spec passes (`cleanup by a loose foreign key` shared example). - [ ] **No behaviour change** to any existing routing or UI — this issue introduces infrastructure only. --- ## Out of scope - The `duo_code_review_flow_priority` feature flag (Issue 2). - Any change to `Modes::Dap#active?` routing logic (Issue 2). - Writing consent records from the UI (Issues 4 & 5). - Any frontend changes.
issue