mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
feat(suitecrm): authenticate against Keycloak via SAML instead of a proxy
The role sat behind the oauth2-proxy, which authenticates at the edge and hands the app nothing: SuiteCRM kept serving its own Angular login after the Keycloak round trip, so no session ever existed and the app had no logout control. CI run 31927586060 is the only run in which the role's Playwright spec ever executed, and it failed exactly there - "no in-app logout control reachable on the current authenticated surface" at personas/utils/logout.js. SuiteCRM 8.9.1 ships nbgrp/onelogin-saml-bundle and treats AUTH_TYPE=saml as a first-class firewall in config/packages/security.php:249, so the app can own its session without a line of PHP from us. flavor: saml turns the proxy off by itself - is_proxy_gated is `enabled AND flavor == 'oauth2'` - which also drops the redis session store and the auth_request clamp in the vhost. The role declares no allowed_groups, so no edge ACL is lost. Four settings are not cosmetic; each is a failure reproduced against a live Keycloak 26.7.1 until the login completed: - SAML_COMPRESS_REQUESTS: the HTTP-Redirect binding requires DEFLATE, and Keycloak rejects an uncompressed AuthnRequest with a ZipException. - SAML_USE_ATTRIBUTE_FRIENDLY_NAME=false: the bundle defaults to matching attributes by friendlyName, which Keycloak's mappers do not set, so not a single attribute arrives. - APP_DEBUG is forced to 0 whenever SAML is on: public/legacy/modules/Users/ User.php:944 reads $_POST['ut'] unguarded while creating the user, and with debug on Symfony turns that warning into an exception the authenticator reports as a failed login - the account is created and the FIRST login of every new user fails anyway. - SAML_SP_ENTITY_ID must equal the Keycloak client's clientId. The IdP signing certificate is read from the realm descriptor at boot rather than templated in, so a key rotation needs no redeploy. It uses the same public issuer URL the browser is redirected to - one address, not two - with the SOCKS proxy espocrm established for onion issuers. An unreachable IdP or a descriptor without a certificate aborts the start; both paths are verified, as is the skip when the variable is absent on LDAP/native deployments. Verified end to end locally against Keycloak 26.7.1, MariaDB 12.3 and SuiteCRM 8.9.1 from the upstream tarball: session-status reports active:true for the Keycloak user, auto-created on the first attempt against an empty users table. web-app-keycloak/TODO.md listed this role for the trusted-header bridge. Nothing of it was implemented, and SuiteCRM exposes no header entry point at all (REMOTE_USER / X-Forwarded-User / X-Auth-Request appear nowhere in core/ or public/legacy), so it moves to the same exclusion as jitsi: use the native mechanism the app already ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6b6a0ce27e
commit
d15716643a
7 files changed
+56
-17
No files matched your search
@@ -19,6 +19,5 @@ Roles still to migrate to the trusted-header SSO bridge (pattern: `web-app-baser
|
||||
- `web-app-friendica` — addon/hook: header → session
|
||||
- `web-app-akaunting` — Laravel controller shim (stage shim into the named volume on every deploy)
|
||||
- `web-app-magento` — custom `Infinito_HeaderSso` module (bypass FPC/Varnish on the handoff route)
|
||||
- `web-app-suitecrm` — header → session shim
|
||||
- `web-app-funkwhale` — django header / remote-user auth backend
|
||||
- Excluded: `web-app-flowise` (session-minting code is commercial-licensed), `web-app-jitsi` (no HTTP session — use native `AUTH_TYPE=jwt`)
|
||||
- Excluded: `web-app-flowise` (session-minting code is commercial-licensed), `web-app-jitsi` (no HTTP session — use native `AUTH_TYPE=jwt`), `web-app-suitecrm` (upstream SAML — use native `AUTH_TYPE=saml`)
|
||||
@@ -127,11 +127,16 @@ docker run --rm -it \
|
||||
`extensions/<software>/config/services/ldap/`, which maps the LDAP attributes onto
|
||||
SuiteCRM's user fields for auto-created accounts.
|
||||
|
||||
- **SSO** reaches the app through the oauth2-proxy declared in
|
||||
[`meta/services.yml`](./meta/services.yml) (`sso.flavor: oauth2`), which terminates the
|
||||
Keycloak OIDC flow in front of SuiteCRM. SuiteCRM itself is not OIDC-aware; its own
|
||||
SAML and OAuth provider settings stay untouched by this role and remain available in
|
||||
the Administration panel.
|
||||
- **SSO** is SuiteCRM's own SAML login against Keycloak (`sso.flavor: saml` in
|
||||
[`meta/services.yml`](./meta/services.yml)), not a proxy in front of it. Unauthenticated
|
||||
requests hit the Symfony firewall's SAML entry point, which redirects to Keycloak;
|
||||
the assertion comes back to `/saml/acs` and establishes a real SuiteCRM session, so the
|
||||
app renders its own authenticated UI including its logout control. Accounts are created
|
||||
on first login from the assertion (`SAML_AUTO_CREATE`), and the IdP signing certificate
|
||||
is read from Keycloak's realm descriptor by
|
||||
[`files/docker-entrypoint-suitecrm.sh`](./files/docker-entrypoint-suitecrm.sh) at boot,
|
||||
container to container, because the canonical Keycloak URL is unresolvable from inside
|
||||
the app container under Tor. `/auth` stays reachable as the local break-glass login.
|
||||
|
||||
## Persona contract opt-outs
|
||||
|
||||
|
||||
@@ -16,6 +16,18 @@ if [ ! -d "$APP_DIR" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${SUITECRM_SAML_IDP_DESCRIPTOR_URL:-}" ]; then
|
||||
log "Fetching the IdP signing certificate from ${SUITECRM_SAML_IDP_DESCRIPTOR_URL}..."
|
||||
_descriptor=$(wget -qO- --tries=30 --waitretry=5 --retry-connrefused "$SUITECRM_SAML_IDP_DESCRIPTOR_URL")
|
||||
SAML_IDP_X509CERT=$(printf '%s' "$_descriptor" | sed -n 's:.*X509Certificate>\([^<]*\)</[^>]*X509Certificate>.*:\1:p' | head -n 1)
|
||||
if [ -z "$SAML_IDP_X509CERT" ]; then
|
||||
log "ERROR: no X509Certificate in the IdP descriptor."
|
||||
exit 1
|
||||
fi
|
||||
export SAML_IDP_X509CERT
|
||||
log "IdP certificate loaded."
|
||||
fi
|
||||
|
||||
TMPDIR="${APP_DIR}/tmp"
|
||||
export TMPDIR
|
||||
mkdir -p "$TMPDIR"
|
||||
|
||||
@@ -4,11 +4,7 @@ sso:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-keycloak' in group_names }}"
|
||||
shared: "{{ 'web-app-keycloak' in group_names }}"
|
||||
flavor: oauth2
|
||||
oauth2:
|
||||
origin:
|
||||
host: suitecrm
|
||||
port: "80"
|
||||
flavor: saml
|
||||
logout:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-svc-logout' in group_names }}"
|
||||
@@ -62,7 +58,6 @@ suitecrm:
|
||||
from_name: Customer Relationship Management (SuiteCRM, {{ DOMAIN_PRIMARY }})
|
||||
ports:
|
||||
local:
|
||||
sso: 16490
|
||||
http: 8062
|
||||
internal:
|
||||
http: 80
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% include 'roles/sys-svc-container/templates/env.j2' %}
|
||||
|
||||
APP_ENV=prod
|
||||
APP_DEBUG={{ MODE_DEBUG | bool | ternary(1, 0) }}
|
||||
APP_DEBUG={{ (MODE_DEBUG | bool and not (SUITECRM_SSO_ENABLED | bool)) | ternary(1, 0) }}
|
||||
|
||||
SERVER_SCHEME={{ lookup('tls', application_id, 'protocols.web') }}
|
||||
TRUSTED_PROXIES={{ NETWORK_TRUSTED_PROXY_CIDRS | join(',') }}
|
||||
@@ -21,7 +21,29 @@ SUITECRM_ADMIN_EMAIL={{ lookup('users', 'administrator').email }}
|
||||
|
||||
SUITECRM_URL={{ SUITECRM_URL }}
|
||||
|
||||
{% if SUITECRM_LDAP_ENABLED | bool %}
|
||||
{% if SUITECRM_SSO_ENABLED | bool %}
|
||||
AUTH_TYPE=saml
|
||||
|
||||
SAML_IDP_ENTITY_ID={{ OIDC.CLIENT.ISSUER_URL }}
|
||||
SAML_IDP_SSO_URL={{ [OIDC.CLIENT.ISSUER_URL, 'protocol/saml'] | path_join }}
|
||||
SAML_IDP_SLO_URL={{ [OIDC.CLIENT.ISSUER_URL, 'protocol/saml'] | path_join }}
|
||||
SAML_SP_ENTITY_ID={{ SUITECRM_SAML_SP_ENTITY_ID }}
|
||||
|
||||
SAML_COMPRESS_REQUESTS=true
|
||||
SAML_USE_ATTRIBUTE_FRIENDLY_NAME=false
|
||||
SAML_USERNAME_ATTRIBUTE={{ OIDC.ATTRIBUTES.USERNAME }}
|
||||
SAML_AUTO_CREATE=enabled
|
||||
SAML_AUTOCREATE_ATTRIBUTES_MAP={"{{ OIDC.ATTRIBUTES.USERNAME }}":"user_name","email":"email1","firstName":"first_name","lastName":"last_name"}
|
||||
|
||||
SUITECRM_SAML_IDP_DESCRIPTOR_URL={{ SUITECRM_SAML_IDP_DESCRIPTOR_URL }}
|
||||
{% if SUITECRM_OIDC_SOCKS_PROXY | length > 0 %}
|
||||
|
||||
http_proxy={{ SUITECRM_OIDC_SOCKS_PROXY }}
|
||||
https_proxy={{ SUITECRM_OIDC_SOCKS_PROXY }}
|
||||
no_proxy=localhost,127.0.0.1
|
||||
{% endif %}
|
||||
|
||||
{% elif SUITECRM_LDAP_ENABLED | bool %}
|
||||
AUTH_TYPE=ldap
|
||||
|
||||
LDAP_AUTO_CREATE=enabled
|
||||
|
||||
@@ -20,7 +20,7 @@ CSS_SERVICE_ENABLED={{ lookup('config', application_id, 'services.css.enabled')
|
||||
# nocheck: playwright-service-gate — the role wires no SMTP at all: templates/env.j2 renders no mail key and services.suitecrm.email.from_name is read by nothing, so the flag has no surface to gate
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
|
||||
# nocheck: playwright-service-gate — only swaps SuiteCRM's own AUTH_TYPE to ldap; the personas enter through the oauth2-proxy OIDC chain, which never binds
|
||||
# nocheck: playwright-service-gate — only swaps SuiteCRM's own AUTH_TYPE to ldap, and only when SSO is off; with SSO on, templates/env.j2 selects saml and the LDAP block is never rendered
|
||||
LDAP_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ldap.enabled') | string | lower }}
|
||||
|
||||
LOGOUT_SERVICE_ENABLED={{ lookup('config', application_id, 'services.logout.enabled') | string | lower }}
|
||||
@@ -32,7 +32,7 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
|
||||
# nocheck: persona-block-rationale — templates/env.j2:20-22 seeds only the administrator into SuiteCRM and biber reaches the app solely through the oauth2-proxy (meta/services.yml:7), so the sso: false variants leave biber without a native account (see README "Persona contract opt-outs").
|
||||
# nocheck: persona-block-rationale — with SSO on, SuiteCRM authenticates through SAML against Keycloak and auto-creates the account on first login; with SSO off, templates/env.j2 seeds only the administrator, so biber has no account at all (see README "Persona contract opt-outs").
|
||||
PERSONA_BIBER_BLOCKED={{ 'false' if (lookup('config', application_id, 'services.sso.enabled') | bool) else 'true' }}
|
||||
|
||||
SSO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.sso.enabled') | string | lower }}
|
||||
@@ -22,6 +22,12 @@ SUITECRM_SEED_DIR: /usr/src/suitecrm
|
||||
SUITECRM_WEB_USER: www-data
|
||||
SUITECRM_WEB_GROUP: www-data
|
||||
|
||||
SUITECRM_SSO_ENABLED: "{{ lookup('sso', application_id, 'is_saml') }}"
|
||||
SUITECRM_SAML_IDP_DESCRIPTOR_URL: "{{ [OIDC.CLIENT.ISSUER_URL, 'protocol/saml/descriptor'] | path_join }}"
|
||||
SUITECRM_SAML_SP_ENTITY_ID: "{{ [SUITECRM_URL, 'saml/metadata'] | path_join }}"
|
||||
SUITECRM_OIDC_ONION_ISSUER: "{{ lookup('tls', 'web-app-keycloak', 'domain').endswith('.onion') }}"
|
||||
SUITECRM_OIDC_SOCKS_PROXY: "{{ lookup('tor_socks_proxy') if SUITECRM_OIDC_ONION_ISSUER | bool else '' }}"
|
||||
|
||||
SUITECRM_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
|
||||
SUITECRM_LDAP_CONFIG_HOST: "{{ [ lookup('container', application_id, 'directories.config'), 'ldap.yaml'] | path_join }}"
|
||||
SUITECRM_LDAP_EXTENSION_DIR: "{{ [SUITECRM_APP_DIR, 'extensions', SUITECRM_WELL_SOFTWARE_NAME, 'config/services/ldap'] | path_join }}"
|
||||
|
||||
Reference in new issue
Block a user