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