fix(web-app-suitecrm): let the external-auth provisioning path create its user

SuiteCRM 8.10.2 tightened User::hasSaveAccess() from return true to is_admin($current_user) for a record without an id, and restrictAdminOnlyFields() now forces status Inactive on such a record. CreateUser::create() runs while the browser is still anonymous, because it is the LDAP and SAML auto-create path, so every FIRST login of a directory account ends in RuntimeException: Not authorized and HTTP 500 on POST /login. The pre-existing administrator is unaffected, which is why one persona passes and one fails in both auth modes. 451f63cbdd carried the role from 8.9.1 to 8.10.2; 8.9.1 exempted a new record from both guards.

The patch raises the admin context around that one save and restores it in a finally, rather than relaxing the guards, which stay closed for every request-driven save. create() has a single caller in its own file, createExternalAuthUser, which sets is_admin 0 and external_auth_only 1 before calling, so the raised context cannot mint an administrator. patch runs with -F0 so a hunk upstream has moved fails the build instead of landing somewhere else.

The patch path is derived once in vars/main.yml and reaches the Dockerfile as a build arg, following the role's existing SUITECRM_ENTRYPOINT_SCRIPT_* shape, so the file name is written once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-10-15 11:28:55 +02:00
1 parent db1a27b3f2
commit 495d23dbde
6 files changed
+82

No files matched your search

+8
View File
@@ -66,6 +66,14 @@ Role-level extensions are declared in `meta/addons/`, one file per addon:
the Tor SOCKS proxy whenever the issuer is an onion, because a `.onion` issuer is
unresolvable directly. `/auth` stays reachable as the local break-glass login.
- **Auto-created accounts** reach `User::save()` through SuiteCRM's own `CreateUser`
while the request is still anonymous. Since 8.10.2 that save answers
`RuntimeException: Not authorized` and forces `status = Inactive`, so
`files/patch/external-auth-user-create.patch` raises an admin context around the save
and restores the caller's afterwards. [`files/Dockerfile`](./files/Dockerfile) applies
it with `patch -F0` on the unpacked release; the image build fails once the hunk stops
matching.
## Persona contract opt-outs
[`templates/env.j2`](./templates/env.j2) seeds only the administrator into SuiteCRM (`SUITECRM_ADMIN_USERNAME` / `SUITECRM_ADMIN_PASSWORD`); `biber` reaches the app through SuiteCRM's own SAML login against Keycloak, where `SAML_AUTO_CREATE` mints the account on first assertion. In the `services.sso.enabled: false` matrix variants there is no SAML entry point and `biber` has no native account, so [`templates/playwright.env.j2`](./templates/playwright.env.j2) renders `PERSONA_BIBER_BLOCKED=true` for the generic persona journeys (the LDAP spec still signs `biber` in from the directory). The `administrator` persona keeps its native login via `ADMIN_NATIVE_PASSWORD` and runs in every variant.
+8
View File
@@ -19,6 +19,7 @@ RUN rm -rf /var/lib/apt/lists/* \
libonig-dev \
libxml2-dev \
libldap2-dev \
patch \
&& docker-php-ext-configure ldap --with-libdir=lib/x86_64-linux-gnu \
&& docker-php-ext-install \
pdo \
@@ -56,6 +57,8 @@ FROM base AS builder
ARG SUITECRM_APP_VERSION
ARG SUITECRM_APP_DIR
ARG SUITECRM_LDAP_EXTENSION_DIR
ARG SUITECRM_EXTERNAL_AUTH_PATCH_HOST_REL
ARG SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER
WORKDIR ${SUITECRM_APP_DIR}
@@ -69,6 +72,11 @@ RUN set -eux; \
rm /tmp/suitecrm.tar.gz; \
chmod +x bin/console
COPY ${SUITECRM_EXTERNAL_AUTH_PATCH_HOST_REL} ${SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER}
RUN set -eux; \
patch -p1 -F0 -i ${SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER}; \
rm ${SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER}
RUN set -eux; \
composer install \
--prefer-dist \
@@ -0,0 +1,50 @@
SuiteCRM 8.10.2 tightened User::hasSaveAccess() from `return true` to
`return is_admin($current_user)` for a record without an id, and
restrictAdminOnlyFields() now forces `status = Inactive` on such a record
(release commit 6c7ce002a). CreateUser::create() runs while the browser is
still anonymous, because it is the LDAP and SAML auto-create path
(AppLdapUserProvider::createUser, AppSamlUserFactory::createUser, both via
UserHandler::createExternalAuthUser), so every first login of a directory
account ends in `RuntimeException: Not authorized`, HTTP 500 on POST /login
and no session after a SAML assertion. v8.10.1 and earlier exempted a new
record from both guards; upstream has no release and no hotfix carrying a fix.
\CreateUser is reachable only from that provisioning stack and from the
installer, so the admin context is raised here instead of relaxing the guards,
which keeps them closed for every request-driven save.
Generated with `diff -u` against the v8.10.2 tarball and applied with `-F0`, so
the build fails instead of letting GNU patch's default fuzz factor land the
hunk somewhere upstream moved it to.
--- a/public/legacy/include/portability/User/CreateUser.php
+++ b/public/legacy/include/portability/User/CreateUser.php
@@ -68,6 +68,8 @@
*/
public function create(string $name, array $fields): ?SugarBean
{
+ global $current_user;
+
$user = BeanFactory::newBean('Users');
$user->user_name = $name;
@@ -77,7 +79,18 @@
$user->$key = $value;
}
- $user->save();
+ $callerUser = $current_user;
+
+ if (!is_admin($current_user)) {
+ $current_user = BeanFactory::newBean('Users');
+ $current_user->is_admin = 1;
+ }
+
+ try {
+ $user->save();
+ } finally {
+ $current_user = $callerUser;
+ }
if (empty($user)) {
return null;
@@ -30,6 +30,14 @@
- compose-up
- compose-build
- name: "🚀 Deploy '{{ SUITECRM_EXTERNAL_AUTH_PATCH_HOST_ABS }}'"
copy:
src: "{{ SUITECRM_EXTERNAL_AUTH_PATCH_SRC }}"
dest: "{{ SUITECRM_EXTERNAL_AUTH_PATCH_HOST_ABS }}"
notify:
- compose-up
- compose-build
- name: "🐳 Docker Compose Up for '{{ application_id }}'"
meta: flush_handlers
@@ -9,6 +9,8 @@
SUITECRM_PHPREDIS_VERSION: "{{ SUITECRM_PHPREDIS_VERSION }}"
SUITECRM_ENTRYPOINT_SCRIPT_HOST_REL: "{{ SUITECRM_ENTRYPOINT_SCRIPT_HOST_REL }}"
SUITECRM_ENTRYPOINT_SCRIPT_DOCKER: "{{ SUITECRM_ENTRYPOINT_SCRIPT_DOCKER }}"
SUITECRM_EXTERNAL_AUTH_PATCH_HOST_REL: "{{ SUITECRM_EXTERNAL_AUTH_PATCH_HOST_REL }}"
SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER: "{{ SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER }}"
SUITECRM_LDAP_EXTENSION_DIR: "{{ SUITECRM_LDAP_EXTENSION_DIR }}"
SUITECRM_APP_DIR: "{{ SUITECRM_APP_DIR }}"
SUITECRM_PHP_MEMORY_LIMIT: "{{ SUITECRM_PHP_MEMORY_LIMIT }}"
+6
View File
@@ -44,6 +44,12 @@ SUITECRM_ENTRYPOINT_SCRIPT_HOST_ABS: "{{ [ lookup('container', application_id, '
SUITECRM_ENTRYPOINT_SCRIPT_HOST_REL: "volumes/{{ SUITECRM_ENTRYPOINT_SCRIPT_FILE }}"
SUITECRM_ENTRYPOINT_SCRIPT_DOCKER: "{{ [ '/usr/local/bin/', SUITECRM_ENTRYPOINT_SCRIPT_FILE ] | path_join }}"
SUITECRM_EXTERNAL_AUTH_PATCH_FILE: "external-auth-user-create.patch"
SUITECRM_EXTERNAL_AUTH_PATCH_SRC: "patch/{{ SUITECRM_EXTERNAL_AUTH_PATCH_FILE }}"
SUITECRM_EXTERNAL_AUTH_PATCH_HOST_ABS: "{{ [ lookup('container', application_id, 'directories.volumes'), SUITECRM_EXTERNAL_AUTH_PATCH_FILE ] | path_join }}"
SUITECRM_EXTERNAL_AUTH_PATCH_HOST_REL: "volumes/{{ SUITECRM_EXTERNAL_AUTH_PATCH_FILE }}"
SUITECRM_EXTERNAL_AUTH_PATCH_DOCKER: "{{ [ '/tmp/', SUITECRM_EXTERNAL_AUTH_PATCH_FILE ] | path_join }}"
SUITECRM_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}"
SUITECRM_DESIGN_LOGO_ENABLED: "{{ SUITECRM_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string }}"
SUITECRM_DESIGN_TITLE: "{{ lookup('design', application_id).title if SUITECRM_DESIGN_ENABLED | bool and lookup('design', application_id).title is string else '' }}"