Skip to content

feat(auth): migrate dotAuth (OAuth/OIDC + SAML) from plugin to core#36228

Open
swicken wants to merge 134 commits into
mainfrom
feat/dotauth-core-migration
Open

feat(auth): migrate dotAuth (OAuth/OIDC + SAML) from plugin to core#36228
swicken wants to merge 134 commits into
mainfrom
feat/dotauth-core-migration

Conversation

@swicken

@swicken swicken commented Jun 18, 2026

Copy link
Copy Markdown
Member

Closes #35555

Supersedes #35421 (same branch; could not be reopened).

Proposed Changes

Migrates the OAuth 2.0 / OIDC provider from the legacy dotOAuth plugin into core, adds SAML support as a first-class protocol alongside OAuth, introduces a headless token-exchange flow for front-end SPAs, and replaces the old dotsaml-config / dotOAuth app-key editors with a unified dotAuth portlet under Settings.


Backend (dotCMS/src)

REST API — /v1/dotauth

  • Full CRUD for per-host dotAuth configs (DotAuthResource, DTOs, wired into DotRestApplication).
  • Config bundle import/export endpoints (encrypted Apps export file, dotAuth-only filter on import).
  • OIDC discovery proxy — fetches .well-known/openid-configuration and returns parsed issuer, endpoints, JWKS URI, and signing algorithms.
  • Session-ref revocation endpoint (flushes the dotAuth sessionRef cache).

Protocol layer

  • DotAuthProtocol enum + ProtocolHandler strategy interface.
  • OAuthProtocolHandler — extracted from the legacy plugin; hardened OIDC iss check, alg validation, session binding.
  • SamlProtocolHandler — new; own secret-key set, metadata endpoint, custom attribute mapping.
  • Dual-key read/write/delete with case-insensitive host-id lookup; key rename dotOAuthdotAuth.

Headless token exchange

  • OAuth exchange endpoint (/v1/dotauth/exchange) — accepts an authorization code from an SPA, validates against the OIDC provider, provisions or resolves the user, and returns a dotCMS session-ref bearer token.
  • DotAuthSession model + cache for session-ref tokens; DotAuthSessionCredentialProcessor wired into the filter chain.
  • BuildRolesStrategy — configurable role sync (merge / replace / none) on each login.

Security hardening

  • SSRF DNS-rebinding prevention on the OIDC discovery proxy (shared OAuthSsrfGuard).
  • 60s clock-skew tolerance + fail-closed replay-fingerprint check on OIDC id_token.
  • Admin-only guard on session-ref revocation.
  • No OIDC validation details leaked in error responses.
  • OAuth callback URL auto-derived from request (no user-supplied redirect target).
  • Response size limits and redirect sanitization on the OAuth callback.

Portlet registration

  • Startup task (Task260420AddDotAuthPortletToMenu) registers the dotAuth portlet in the Settings layout.
  • portlet.xml registration; /apps/dotsaml-config redirects to the dotAuth portlet; dotsaml-config hidden from the Apps grid.

Frontend (core-web)

libs/portlets/dot-auth (new Nx library)

  • Data-access service, discriminated-union models on protocol, list + config SignalStores.
  • List view — protocol column, composed status tag, per-host CRUD.
  • SSO config page — protocol toggle (OAuth / SAML), OIDC discovery auto-fill, SAML fieldsets (metadata URL/fetch, custom attributes, SP keypair auto-generation, metadata download), auto-provision and role-sync strategy controls.
  • Headless config page — separated from SSO; system-level headless security settings with own app key.
  • Config bundle import/export UI.
  • Shell, routes, and i18n keys.

Modernization

  • PrimeNG migration (p-inputSwitchp-toggleswitch, pTextarea + TextareaModule), Tailwind v4 var-shorthand normalization.
  • Components aligned with ANGULAR_STANDARDS (signals, @if/@for, OnPush).
  • Regenerated OpenAPI client.

Testing

  • Backend: JUnit 5 unit tests for DotAuthResource, protocol handlers, exchange guard paths, role-strategy, OIDC claim validation, SSRF guard, mapper round-trips.
  • Frontend: Spectator specs for list component, edit dialog, SignalStores, SAML + protocol-switch paths, mapper round-trips.

Rollback notes

The startup task inserts a dotAuth portlet entry into cms_layouts_portlets and shifts existing portlet order values. On rollback to N−1 this entry becomes orphaned. Manual cleanup required:

DELETE FROM cms_layouts_portlets WHERE portlet_id = 'dotAuth';

A CDN purge of the Angular admin bundle may also be needed if a caching proxy is in front of the admin UI.


Checklist

  • Tests — backend unit + frontend Spectator coverage across all major paths
  • Translations — i18n keys for dotAuth labels, protocols, fieldsets, and error states
  • Security — SSRF prevention, OIDC hardening (clock skew, replay), no leaked validation details, admin-only endpoints, no hardcoded secrets

Stats

126 files changed, ~16k LOC added

@semgrep-code-dotcms-test

Copy link
Copy Markdown
Contributor

Legal Risk

The following dependencies were released under a license that
has been flagged by your organization for consideration.

Recommendation

While merging is not directly blocked, it's best to pause and consider what it means to use this license before continuing. If you are unsure, reach out to your security team or Semgrep admin to address this issue.

GPL-2.0

MPL-2.0

swicken added 28 commits June 18, 2026 16:19
Bring the dotCMS OAuth plugin into core under
com.dotcms.auth.providers.oauth with a new dotOAuth app key. Wires the
web interceptor, auto-login filter, REST callback, viewtools, and
default YAML template. Rejects plugin REST resources whose @path
collides with a migrated core resource.
Absorb SAML configuration into the dotAuth portlet so admins edit OAuth
and SAML through a single UI, with OAuth/SAML secrets kept in their
existing AppSecrets keys. Strategy pattern via ProtocolHandler keeps
DotAuthResource thin. dotsaml-config.yml removed in the final commit
once the Okta E2E checkpoint passes.
swicken added 19 commits June 18, 2026 16:20
…rtlet count

DotAuthResourceTest mocked the old findAll() but listSites now uses the
paginated search() API; SerializationHelperTest expected 51 portlets but
the dotAuth portlet entry brought the count to 52.
… id_token_hint

Three bugs fixed:

1. autoProvision flag was sent by the UI but never stored or enforced.
   Added the field to OAuthAppConfig, wired it through OAuthProtocolHandler
   SECRET_KEYS/BOOLEAN_KEYS, and added a guard in OAuthHelper that rejects
   unknown users when autoProvision is false.

2. Trusted IdP claim/role settings were silently ignored. The exchange
   resource matched a trusted IdP but only applied issuer/audience/groupsClaim
   before passing the global config to resolveOrProvisionUser. Added
   OAuthAppConfig.withTrustedIdpOverrides() to overlay per-IdP claimEmail,
   claimFirstName, claimLastName, groupMappings, roleBehavior, defaultRoles,
   and autoProvision onto the base config.

3. RP-initiated logout sent the access token as id_token_hint. The callback
   validated the id_token but only stored the access token in session. Now
   the id_token is stored alongside the access token and used for the
   getLogoutUrl() call.
…ssuer

Two security fixes:

1. The /discover/oidc and /fetch/saml-metadata proxy endpoints accepted
   arbitrary URLs and passed them directly to CircuitBreakerUrl without
   scheme, HTTPS, or host validation. A comprehensive fetchJson() with
   DNS-pinned connections was previously removed in aa87ac0 and
   replaced with raw CircuitBreakerUrl calls. Added validateProxyUrl()
   to restore scheme + HTTPS + OAuthSsrfGuard host validation, matching
   the same standard OAuthAppConfig.validateUrl() applies elsewhere.

2. OAuth user IDs were namespaced only by provider type (always "OIDC"
   for all OIDC providers), so two trusted IdPs emitting the same
   subject claim would collide on one dotCMS user row — a confused-deputy
   that lets the second login authenticate as the first user. Now includes
   the verified issuer in the namespace (sanitized for ID-safe characters).
   Backward-compat candidates still try the old no-issuer format so
   existing single-issuer users aren't stranded.
…lidate SSRF guard

1. Trusted IdP roleBehavior values from the UI (idp-only, static-only,
   additive, sync-all, none) were stored as-is in the JSON config but
   BuildRolesStrategy.resolve() only accepts Java enum names (IDP,
   STATICONLY, STATICADD, ALL, NONE). Every value except "none" silently
   fell back to ALL, wiping roles contrary to the admin's saved config.
   Added translateRoleBehavior() in normalizeIdpMapValues to convert
   UI values to enum names before the overlay.

2. Trusted IdP issuer URLs from the raw JSON config were passed directly
   to OIDCProvider without scheme, HTTPS, or SSRF host validation. Added
   validateIssuerUrl() check before constructing the provider so trusted
   IdP issuers go through the same guard as every other OAuth URL.

3. Consolidated the duplicate SSRF+scheme+HTTPS validation logic into
   OAuthSsrfGuard.validateUrl(). DotAuthResource.validateProxyUrl() and
   DotAuthOAuthExchangeResource.validateIssuerUrl() now both delegate to
   the shared method.

4. Fixed raw <strong> HTML tags rendering as text in the clear-config
   confirmation dialog by switching from {{ }} interpolation to
   [innerHTML] binding.
…once timing

Three OIDC spec compliance and security hardening improvements:

1. JWKS cache now uses Nimbus JWKSourceBuilder with TTL (5 min default)
   and automatic retry on verification failure. Previously, a stale
   RemoteJWKSet was cached indefinitely per jwks_uri with no refresh
   mechanism, so IdP key rotation (Azure AD rotates weekly) caused all
   logins to fail until the JVM restarted or the cache was evicted.

2. Added at_hash validation per OIDC Core §3.2.2.9. When the token
   response includes both access_token and id_token, the at_hash claim
   (left half of the access token hash) is verified to prevent token
   substitution attacks. Validation is skipped when at_hash is absent
   (optional in the authorization code flow).

3. Nonce comparison in verifyIdTokenClaims now uses constant-time
   MessageDigest.isEqual() instead of String.equals(), matching the
   state parameter check already in OAuthWebInterceptor. Closes a
   theoretical timing side-channel.
…orrectness

Use a timestamp marker (LAST_SAVED_PROTOCOL_AT_KEY) to resolve protocol
ambiguity when both OAUTH and SAML rows exist after a failed cleanup,
instead of relying on EnumMap iteration order.

Also: defer success/clear toasts until async completion, add 'error' status
to prevent stale toast display, fix saveSso return value for protocol=none,
stop clearing headless config on SSO clear, fix signatureValidationType
enum value, and correct back-navigation route depth.
…sanitization

Refuse to derive OAuth callback URL from the Host header — require
explicit callbackUrl configuration to prevent header injection.

Add BoundedOutputStream to CircuitBreakerUrl so OIDC discovery (1MB)
and SAML metadata (5MB) fetches cannot exhaust server memory.

Fix sanitizeRedirect to allow colons in query strings while still
blocking protocol-like colons in the path segment.
…check

Front-end SAML logins redirect to content URLs and must not be denied by the
back-end role gate. Restrict the no-access check to back-end login paths and
reuse SamlWebUtils.isBackEndLoginPage so the back-end path set lives in one place.
- HIGH: add one-time-use replay guard for exchanged id_tokens (token-hash cache);
  reject re-presentation until exp. Correct misleading nonce Javadoc.
- Fix clampToIdpExp dead code (claim exp is a java.util.Date, not a Number).
- Bump nimbus-jose-jwt to 9.37.4 (CVE-2025-53864).
- Sanitize CRLF in exchange security logs; XML-escape SP-metadata certificate.
- Bound OIDCProvider outbound fetches (setMaxResponseBytes); add azp check for
  multi-audience id_tokens; fail closed on present-but-unverifiable at_hash.
- Add dotAuth package to swagger resourcePackages (regenerate openapi.yaml);
  unify dotAuth tag descriptions; de-duplicate i18n keys; size session/replay caches.
- FE: suppress false saved toast on failed post-save reload; remove
  write-to-void session-TTL/audience/responseType/pkce inputs; delete dead dot-demo.
- Tests: OAuthSsrfGuard, exchange CORS/trusted-IdP branches, sanitizeRedirect vectors.
- CircuitBreakerUrl: enforce maxResponseBytes on every consumption path and
  fail loudly (ResponseSizeLimitExceededException) instead of returning a
  silently truncated body with a 2xx status
- OAuthWebInterceptor: only take over logout for sessions created via OAuth;
  native/basic/SAML sessions keep the standard logout contract. Route the
  pre-redirect role gate through AuthAccessDeniedUtil so admins are treated
  consistently on both flow legs
- OIDCProvider: don't emit a dangling '?' on the end-session logout URL
- OAuthHelper: deterministic subject resolution (sub/id/user_id/oid, verified
  email as last resort) instead of a per-login random UUID that broke generic
  OAuth2 providers after the first login; refuse login when no stable identity
  can be established
- DotSamlResource/SAMLHelper: IdP-initiated logins (no RelayState) by users
  without back-end access route to '/' when front-end SSO is enabled instead
  of being 403'd by the defaulted /dotAdmin/ path; centralize
  enableBackend/enableFrontend semantics in SAMLHelper
- DotAuthSessionCacheImpl: make the id_token replay guard's check-then-put
  atomic; correct the javadoc to state the cache is node-local and document
  the session-affinity/distributed-provider requirement
- dot-auth portlet: round-trip idpName, sPEndpointHostname, signRequests,
  revocationUrl and groupsUrl through fromView/toPayload so a UI save can no
  longer blank a working SP hostname or delete stored OAuth secrets
- OIDCProviderClaimValidationTest: align the multi-audience test with the azp
  hardening (was failing on HEAD)
idTokenFingerprint returned null on NoSuchAlgorithmException, which made
registerExchangeTokenUse permissive — skipping replay protection for that
request. SHA-256 is JVM-mandatory, so this was a documented fail-open on a
dead branch. Narrow the catch and throw instead, matching the existing
OAuthCrypto.pkceChallengeS256 behavior.
Mirror Nimbus DefaultJWTProcessor's 60s clock-skew allowance in the
explicit exp gate so it never rejects a token Nimbus already accepted.
@swicken swicken force-pushed the feat/dotauth-core-migration branch from 52fc881 to 1bb0b50 Compare June 18, 2026 20:22
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Jun 18, 2026
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

🤖 Bedrock Review — deepseek.v3.2

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts:76 — The openExport method signature changed to accept DotApp | null, but the JSDoc comment still says "For export all, we don't pass an app". This is misleading; the method now explicitly handles null for "all apps". Update the comment to reflect the new signature.

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-import-export-dialog/store/dot-apps-import-export-dialog.store.ts:185 — The store now uses store._importSuccessSubject.next() instead of the previously module-scoped importSuccessSubject. This is fine, but ensure _importSuccessSubject is properly cleaned up to avoid memory leaks (though it's provided in root). Consider adding a teardown if the store lifecycle is managed.

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts:89 — The openExportDialog method passes null to openExport, but the comment says "we don't pass an app — the store handles null to mean 'all apps'". This is correct, but ensure the backend API correctly interprets null as "export all" (the diff doesn't show backend changes, so assume it's handled).

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts:107 — The method hasExportableApps uses some which is more efficient than filter().length. However, the logic now includes hidden apps (like SAML) in the check because state.allApps() includes all apps fetched from the service. This is intentional per the test, but ensure the UI behavior matches requirements (export button enabled even if only hidden apps have configs).

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts:134 — The withoutHiddenApps function filters out hidden apps for display. This is a design choice to hide SAML from the grid. Ensure the filtering is consistent across all list views (search, initial load). The filterApps method also applies withoutHiddenApps, which is correct.

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/dot-apps-list/dot-apps-list.component.ts:154 — The filterApps method now includes take(1) and takeUntilDestroyed. This prevents multiple subscriptions and ensures cleanup, but note that take(1) will complete the observable after the first emission, which is fine for a search. However, if the service emits multiple values (e.g., via a stream), this might not be intended. Verify the DotAppsService.get returns a single-emission observable (likely an HTTP request).

[🟡 Medium] core-web/apps/dotcms-ui/src/app/portlets/dot-apps/guards/dot-apps-saml-redirect.guard.ts:24 — The guard redirects SAML app routes to /dotAuth. This is a new routing guard; ensure it doesn't interfere with other guards (e.g., MenuGuardService) and that the DOT_AUTH_PATH is correctly registered (it is, per app.routes.ts). Also ensure the guard is only applied to the intended routes (it is, via canActivate in dotAppsRoutes).

[🟡 Medium] core-web/libs/data-access/src/lib/dot-auth/dot-auth.service.ts:129 — The exportBundle method uses a Promise wrapper around an HTTP observable, which is unusual for an Angular service (typically you'd return the observable directly). This could lead to promise rejection handling issues. Consider refactoring to return Observable<string | null> and let the caller subscribe, or ensure errors are properly caught and resolved in the promise.

[🟡 Medium] core-web/libs/data-access/src/lib/dot-auth/dot-auth.service.ts:117 — The exportBundle method creates a blob URL and triggers a download. This is fine, but ensure the blob is properly revoked (URL.revokeObjectURL is called). It is, but note it's called immediately after anchor.click(), which might be too early if the browser hasn't started the download. This is a common pattern but could cause issues in some browsers. Consider moving revokeObjectURL to a setTimeout or use anchor.onclick.

[🟡 Medium] core-web/libs/data-access/src/lib/dot-auth/dot-auth.service.ts:124 — The importBundle method uses FormData with a JSON string for password. This is acceptable, but ensure the backend expects json as a field name (the diff doesn't show backend). Also, the file field name is file; confirm it matches the API contract.

[🟡 Medium] core-web/libs/dotcms-models/src/lib/dot-auth.model.ts:294 — The DotAuthSamlConfigValues interface uses an index signature [extraKey: string]: unknown;. This allows arbitrary extra parameters, which aligns with allowExtraParameters: true in the Apps descriptor. However, using unknown may cause type safety issues in the UI. Consider using string or string | boolean if the extra params are always strings (as per the comment).

[🟠 High] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-headless-section.component.html:188 — The revoke output emits when the "Revoke all" button is clicked, but there's no confirmation dialog. Revoking all session references is a destructive operation. Add a confirmation step (e.g., using DotAlertConfirmService) before emitting the event.

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.html:125 — The clientSecret field shows a "secret stored" UI when isSecretStored() returns true. The "Replace" button calls onChange('clientSecret', ''), which sets the secret to an empty string. Ensure the backend interprets an empty string as "clear the secret" and not "keep the stored secret". The hidden secret mask '****' should be used for preserving.

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-oidc-connection.component.ts:34 — The isSecretStored() method checks for '****' or DOT_AUTH_HIDDEN_SECRET_MASK. This is fine, but ensure the constant DOT_AUTH_HIDDEN_SECRET_MASK is correctly imported and matches the backend's mask string (it does, per the model definition).

[🟡 Medium] core-web/libs/portlets/dot-auth/src/lib/dot-auth-config/components/dot-auth-provisioning.component.html (line not specific) — The component uses DotMessagePipe for i18n. Ensure all message keys (e.g., 'dotauth.field.autoProvision') are defined in the message bundles. This is a test gap; the PR doesn't include updates to message files, so there's a risk of missing translations.

[🟡 Medium] bom/application/pom.xml:1242 — Added a new dependency nimbus-jose-jwt version 9.37.4. This is added to the BOM, which is correct per dotCMS conventions (dependency versions in bom/application/pom.xml). Ensure the version is compatible with the project's Java version (Java 25) and other dependencies.

[🟡 Medium] .mvn/maven-build-cache-config.xml:28 — Disabled Maven build cache (<enabled>false</enabled>). This is likely intentional for CI or local development, but ensure it's documented or justified. No security or bug issue, but could impact build performance.

Note: The diff includes a large number of new files for the dotAuth portlet (components, services, models, styles). The review focuses on the diff provided; many of these files are new and thus don't have existing code to compare against. The implementation appears comprehensive, but without the full context of the backend API, some assumptions are made. Ensure backend integration points (REST endpoints, data shapes) match the frontend models and service calls.


Run: #27787843935 · tokens: in: 22399 · out: 1929 · total: 24328

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Claude finished @swicken's task in 3m 21s —— View job


Rollback Safety Analysis

  • Gather context
  • Read rollback-unsafe categories reference
  • Get full PR diff (4f46f24...956eb42)
  • Analyze diff against all categories
  • Post results and apply label

Pull Request Unsafe to Rollback


Category: H-1 — One-Way Data Migration on cms_layouts_portlets

  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: Task260420AddDotAuthPortletToMenu performs two writes that persist after rollback to N-1: (1) it INSERTs a new dotAuth row into cms_layouts_portlets, which becomes an orphaned row on N-1 (N-1 has no dotAuth portlet and will ignore it, but the row stays); (2) it UPDATEs every portlet whose portlet_order ≥ appsOrder + 1 by incrementing the order by 1 — this is a one-way transformation on existing rows. On rollback to N-1, the shifted portlet_order values persist, so portlets that were after the apps entry appear at +1 from their pre-migration positions. Neither write can be automatically undone by reverting the binary.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java
    • Lines 56–58 (the permanent portlet_order shift):
      private static final String SQL_SHIFT_ORDER =
              "UPDATE cms_layouts_portlets SET portlet_order = portlet_order + 1 "
              + "WHERE layout_id = ? AND portlet_order >= ?";
    • Lines 60–62 (the INSERT that becomes orphaned on rollback):
      private static final String SQL_INSERT =
              "INSERT INTO cms_layouts_portlets(id, layout_id, portlet_id, portlet_order) "
              + "VALUES (?, ?, ?, ?)";
  • Alternative: The PR already documents the orphaned row cleanup SQL (DELETE FROM cms_layouts_portlets WHERE portlet_id = 'dotAuth'). The portlet_order shift is NOT addressed — on rollback the admin Settings menu portlets remain shifted by 1 position from their pre-migration values. To make this fully safe, the task should record the pre-migration portlet_order values or use a reversible approach (no separate manual step is currently documented for the order shift).

Category: H-8 — New VTL Viewtools Added to toolbox.xml

  • Risk Level: 🟢 LOW
  • Why it's unsafe: Two new viewtools are registered in WEB-INF/toolbox.xml$oauth (OAuthTool) and $oauthToken (OAuthTokenTool). N-1 does not have these keys in its toolbox. Any Velocity template (stored in the DB or theme filesystem) that adopts $oauth.isConfigured(), $oauth.getProviderType(), $oauth.getLoginUrl(), $oauth.getLogoutUrl(), or $oauthToken.getAccessToken() during the N release cycle will fail to resolve after rollback, rendering the affected template block blank or erroring. No core dotCMS VTL templates are co-migrated in this PR's diff, so the risk is conditional on customer templates adopting these new keys.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/webapp/WEB-INF/toolbox.xml lines 340–349:
      <tool>
          <key>oauth</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTool</class>
      </tool>
      <tool>
          <key>oauthToken</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTokenTool</class>
      </tool>
  • Alternative: No change needed in this PR since no core templates are co-migrated. Document in release notes that any customer template using $oauth or $oauthToken will fail on rollback to N-1 and must be reverted alongside the binary.

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Pull Request Unsafe to Rollback!!!


Category: H-1 — One-Way Data Migration on cms_layouts_portlets

  • Risk Level: 🟡 MEDIUM
  • Why it's unsafe: Task260420AddDotAuthPortletToMenu performs two writes that persist after rollback to N-1: (1) it INSERTs a new dotAuth row into cms_layouts_portlets, which becomes an orphaned row on N-1 (N-1 has no dotAuth portlet and will ignore it, but the row stays); (2) it UPDATEs every portlet whose portlet_order ≥ appsOrder + 1 by incrementing the order by 1 — this is a one-way transformation on existing rows. On rollback to N-1, the shifted portlet_order values persist, so portlets that were after the apps entry appear at +1 from their pre-migration positions. Neither write can be automatically undone by reverting the binary.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/java/com/dotmarketing/startup/runonce/Task260420AddDotAuthPortletToMenu.java
    • Lines 57–61 (the permanent portlet_order shift):
      private static final String SQL_SHIFT_ORDER =
              "UPDATE cms_layouts_portlets SET portlet_order = portlet_order + 1 "
              + "WHERE layout_id = ? AND portlet_order >= ?";
    • Lines 60–63 (the INSERT that becomes orphaned on rollback):
      private static final String SQL_INSERT =
              "INSERT INTO cms_layouts_portlets(id, layout_id, portlet_id, portlet_order) "
              + "VALUES (?, ?, ?, ?)";
  • Alternative: The PR already documents the orphaned row cleanup SQL (DELETE FROM cms_layouts_portlets WHERE portlet_id = 'dotAuth'). The portlet_order shift is NOT addressed — on rollback the admin Settings menu portlets remain shifted by 1 position from their pre-migration values. To make this fully safe, the task should record the pre-migration portlet_order values or use a reversible approach (no separate manual step is currently documented for the order shift).

Category: H-8 — New VTL Viewtools Added to toolbox.xml

  • Risk Level: 🟢 LOW
  • Why it's unsafe: Two new viewtools are registered in WEB-INF/toolbox.xml$oauth (OAuthTool) and $oauthToken (OAuthTokenTool). N-1 does not have these keys in its toolbox. Any Velocity template (stored in the DB or theme filesystem) that adopts $oauth.isConfigured() or $oauthToken.getAccessToken() during the N release cycle will fail to resolve after rollback, rendering the affected template block blank or erroring. No core dotCMS VTL templates are co-migrated in this PR's diff, so the risk is conditional on customer templates adopting these new keys.
  • Code that makes it unsafe:
    • File: dotCMS/src/main/webapp/WEB-INF/toolbox.xml
    • Lines 340–349:
      <tool>
          <key>oauth</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTool</class>
      </tool>
      <tool>
          <key>oauthToken</key>
          <scope>request</scope>
          <class>com.dotcms.auth.providers.oauth.viewtool.OAuthTokenTool</class>
      </tool>
  • Alternative: No change needed in this PR since no templates are co-migrated. Document in release notes that any customer template using $oauth or $oauthToken will fail on rollback to N-1 and must be reverted alongside the binary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Not Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[EPIC] dotAuth: Migrate Enterprise OAuth/OIDC to Core

1 participant