Persist consent rejection records in OauthConsent
What does this MR do and why?
Persists Authn::OauthConsent records when a user rejects an IAM consent challenge, and updates the data model to support the new rejected status alongside the existing authorized and revoked states.
Previously, the reject flow called the IAM service but did not record the decision in the database. This MR closes that gap so that every consent decision (accept or reject) has a corresponding audit-ready database record.
Changes
Data model (Authn::OauthConsent):
- Enum expanded:
{ authorized: 0, rejected: 1, revoked: 2 }(previously{ authorized: 0, revoked: 1 }). - DB column default removed (
from: 0, to: nil) via post-deployment migration, so every insert must setstatusexplicitly. ADEFAULT 0would silently resolve toauthorized, which is not appropriate for a consent table where every record must represent an explicit user decision. granted_scopesvalidation relaxed for rejected consents (unless: :rejected?), since no scopes are granted on rejection.- Immutability guard renamed from
cannot_update_revoked_consenttocannot_update_terminal_consentand now covers bothrevokedandrejectedstates with a dynamic error message ("#{status_was} consent cannot be modified"). Both states are terminal: rejected consents never issued tokens, revoked consents had tokens invalidated.
Service (RejectConsentChallengeService):
- Now accepts
client_idandrequested_scopes(forwarded from the controller's cached consent data). - After a successful IAM rejection, persists an
Authn::OauthConsentrecord withstatus: :rejectedandgranted_scopes: []. ActiveRecord::RecordInvalid/RecordNotUniquefailures are caught, logged viaGitlab::AuthLogger, and returned asServiceResponse.error(reason: :consent_record_invalid). By design, a persistence failure fails the user flow (the user retries); orphaned IAM-side rejections are cleaned up periodically by the IAM service.
Controller (Iam::ConsentController):
- Passes
client_idandrequested_scopesfrom cached consent data to the reject service.
Note on removing the column default
The post-deployment migration removes the DEFAULT 0 from the status column rather than changing it. This ensures every insert must explicitly set a status — no silent authorized records can be created by accident.
SafelyChangeColumnDefault is not needed because:
- We are removing the default, not changing it to a new value.
- No records exist outside local dev — the feature is gated behind a disabled flag (
iam_svc_login), and the wiring MR (!232772 (merged)) merged in 19.1 which has not shipped to self-managed yet.
The migration down is a no-op: restoring a default that silently resolves to authorized would undermine the explicit-consent design. The pipeline:skip-check-migrations label is applied because CI expects rollback to restore db/structure.sql to its pre-MR state, but the no-op down intentionally does not restore the problematic default.
Important for local development: if you have local records created before this MR, run Authn::OauthConsent.destroy_all in Rails console — the column default was removed, so existing records created with the implicit default may cause inconsistencies.
References
- Issue: #589572
- Depends on: !232772 (merged) (consent flow controller, merged)
- Follow-up: !235073 (merged) (audit events for consent accept/reject)
How to set up and validate locally
Follow the setup instructions in !232772 (merged):
Validating the reject flow
- Drive an authorize URL in the browser, sign in, and click Cancel on the consent screen.
- Verify the redirect to the IAM error URL (
?error=access_denied). - In Rails console, verify the record:
consent = Authn::OauthConsent.last consent.status # => "rejected" consent.granted_scopes # => [] consent.requested_scopes # => ["read_user", "read_api", ...] - Verify the accept flow still works: repeat the authorize URL, click Authorize, and confirm
Authn::OauthConsent.last.status == "authorized"with populatedgranted_scopes.
Validating immutability
consent = Authn::OauthConsent.last # rejected record
consent.status = 'authorized'
consent.valid? # => false
consent.errors[:status] # => ["rejected consent cannot be modified"]MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.