SSO Google + auto-provisioning internal staff
Pracownicy Grupy Dealer (czyli „internal staff” platformy) mają wydzieloną politykę logowania. Krótko:
- Logują się wyłącznie przez Google Workspace SSO.
- Logowanie hasłem i przez Microsoft Entra ID jest dla nich zablokowane.
- Konto tworzy się automatycznie przy pierwszym Google SSO (auto-provisioning), jeśli domena
emaila pasuje do whitelist’y (
grupadealer.pl). - Flaga
app_user.is_internal_staff = TRUEpropaguje się do downstream apek przez claim/userinfo, gdzie apki mogą auto-grantować staff w swojej lokalnej tabeli membership (CRM:iam_user.is_staff = TRUE).
Dlaczego tak
Dział zatytułowany „Dlaczego tak”- Single source of identity dla Grupy Dealer. Pracownik nie powinien mieć osobnego hasła w DealerID — gdy ktoś opuszcza Grupę Dealer, dezaktywuje się konto w Google Workspace i automatycznie traci dostęp do całej platformy.
- Brak shadow IT. Pracownik nie może „obejść” Workspace’a logując się hasłem do DealerID.
- 2FA + audit log na poziomie Google. Wymuszanie 2FA, conditional access, logi — wszystko żyje w Workspace, nie reinwentujemy.
- Microsoft jest dla klientów / dealerów / partnerów. Niektóre dealerstwa mają własną infrę Microsoft 365 — mogą się logować Entrą. Ale nie pracownicy Grupy Dealer.
Konfiguracja — internal_domains
Dział zatytułowany „Konfiguracja — internal_domains”W apps/dealerid/config/services.yaml:
parameters: # SSO — domeny, których właściciele dostają auto-provisioning + status # is_internal_staff (pracownik Grupy Dealer). Tylko Google jest zaufanym # provider'em dla tej operacji — Microsoft i email/hasło nie tworzą kont. app.sso.internal_domains: - 'grupadealer.pl'Dodanie nowej domeny pracowniczej (np. po akwizycji innej spółki) = PR ze zmianą tej listy.
Flow auto-provisioning
Dział zatytułowany „Flow auto-provisioning”sequenceDiagram
autonumber
actor U as User<br/>(lukasz.kopcza@grupadealer.pl,<br/>NIE ma jeszcze konta)
participant Login as DealerID<br/>(/login)
participant Google as Google Workspace
participant Auth as SsoSessionAuthenticator
participant DB as DealerID DB
U->>Login: GET /login
Login-->>U: Form + "Kontynuuj z Google Workspace"
U->>Login: klika "Kontynuuj z Google Workspace"
Login-->>U: 302 → /oauth/google/start
U->>Google: OIDC flow (Google login UI)
Google->>Google: weryfikuje credentials + 2FA
Google-->>U: 302 → /oauth/google/callback?code=...
U->>Login: GET /oauth/google/callback?code=...
Login->>Google: token exchange + userinfo
Google-->>Login: { email, name, sub }
Login->>Auth: authenticate(SsoIdentity{<br/>email, displayName, provider=google})
Auth->>DB: SELECT * FROM app_user WHERE email = ?
DB-->>Auth: null (user nie istnieje)
alt domena ∈ internal_domains (grupadealer.pl)
Auth->>Auth: autoProvisionInternalStaff()<br/>placeholder password (random 64B)<br/>+ is_internal_staff = TRUE
Auth->>DB: INSERT INTO app_user (id, email, name,<br/>password_hash, is_internal_staff = TRUE, ...)
else inna domena
Auth-->>U: SsoUserNotFound exception<br/>"Konto nie istnieje — skontaktuj się z administratorem"
end
Auth->>Auth: UserAuthenticatorInterface::authenticateUser<br/>(symfony session cookie)
Auth-->>U: 302 → /account/apps (zalogowany)
Co jeśli user już istnieje
Dział zatytułowany „Co jeśli user już istnieje”SsoSessionAuthenticator::authenticate():
public function authenticate(SsoIdentity $identity, Request $request): ?Response{ $email = new Email($identity->email); $user = $this->userRepository->findByEmail($email);
if (null === $user) { if ('google' === $identity->provider && $this->isInternalDomain($email)) { $user = $this->autoProvisionInternalStaff($identity, $email); } else { throw new SsoUserNotFound($identity->email); } } elseif ($user->isInternalStaff() && 'google' !== $identity->provider) { throw new InternalStaffMustUseGoogle($identity->email); }
$securityUser = new SecurityUser($user); return $this->userAuthenticator->authenticateUser( $securityUser, new InMemoryAuthenticator($securityUser), $request, );}Logika decyzyjna:
| User w DB? | Provider | Domena ∈ internal | Wynik |
|---|---|---|---|
| Nie istnieje | TAK | Auto-provision z is_internal_staff = TRUE, login OK. | |
| Nie istnieje | NIE | SsoUserNotFound — komunikat „Konto nie istnieje, poproś administratora”. | |
| Nie istnieje | MS | dowolnie | SsoUserNotFound. Microsoft nie auto-provisionuje internal staff (rozmyślnie). |
| Istnieje + is_internal_staff | dowolnie | Login OK. | |
| Istnieje + is_internal_staff | MS | dowolnie | InternalStaffMustUseGoogle — „To konto jest skonfigurowane do Google Workspace”. |
| Istnieje, nie staff | Google / MS / hasło | dowolnie | Login OK (zwykły user). |
Blokada form_login dla internal staff
Dział zatytułowany „Blokada form_login dla internal staff”Pracownik Grupy Dealer nie ma hasła w DealerID — provisioning używa random 64B placeholder
hashed argon2id. Ale gdyby coś poszło nie tak (admin manualnie zmienił is_internal_staff na
TRUE dla zwykłego usera), InternalStaffPasswordLoginBlocker (Symfony UserChecker) odrzuca
login hasłem dla każdego usera z flagą staff. Patrz:
apps/dealerid/src/Module/User/Infrastructure/Security/InternalStaffPasswordLoginBlocker.phpsecurity.yaml:user_checker: App\Module\User\Infrastructure\Security\InternalStaffPasswordLoginBlocker
Placeholder password — czemu istnieje
Dział zatytułowany „Placeholder password — czemu istnieje”Z SsoSessionAuthenticator::autoProvisionInternalStaff():
// Placeholder password — nigdy nie użyty bo staff loguje się przez Google.// Random 64-bajtowy ciąg żeby uniemożliwić brute-force gdyby flag staff// został kiedyś przypadkowo zdjęty.$placeholder = $this->passwordHasher->hash(bin2hex(random_bytes(32)));Schemat encji app_user wymaga password_hash NOT NULL (legacy decyzja, możliwe że zmienimy
w przyszłości). Random 64B + argon2id = nieodgadywalne nawet dla zaawansowanego atakera, więc
gdyby ktoś przypadkowo zdjął flagę is_internal_staff, account dalej nie da się zalogować hasłem.
Propagacja is_internal_staff do downstream apek
Dział zatytułowany „Propagacja is_internal_staff do downstream apek”Dwiema drogami:
1. Claim w /userinfo
Dział zatytułowany „1. Claim w /userinfo”Apka woła /userinfo z access_token usera (scope openid) i dostaje is_internal_staff:
{ "sub": "0192ab...", "email": "lukasz.kopcza@grupadealer.pl", "name": "Łukasz Kopcza", "is_internal_staff": true}CRM JwtAuthenticator::fetchIsInternalStaff() woła to przy pierwszym loginie + propaguje do
ProvisionUserIfMissing:
$isInternalStaff = \in_array('openid', $scopes, true) ? $this->fetchIsInternalStaff($rawToken) : null;$this->commandBus->dispatch(new ProvisionUserIfMissing($sub, $isInternalStaff));2. Auto-grant staff w lokalnej apce
Dział zatytułowany „2. Auto-grant staff w lokalnej apce”ProvisionUserIfMissing w CRM ustawia iam_user.is_staff = TRUE jeśli DealerID powiedziało,
że user jest internal staff. Stamtąd Symfony role ROLE_PLATFORM_STAFF daje bypass dla
ApplicationAccessValidator (staff widzi wszystkie Applications + cross-tenant queries — patrz
Multi-tenancy: Aplikacje):
$iamRoles = ['ROLE_USER'];if ($userInfo->isStaff) { $iamRoles[] = 'ROLE_PLATFORM_STAFF';}Implementacja przykład — co powinna robić nowa apka
Dział zatytułowany „Implementacja przykład — co powinna robić nowa apka”Twoja apka (np. BMS) powinna:
- Lazy-provision lokalnego usera (insert-if-missing po
sub/email). - Przy pierwszym loginie wołać
/userinforaz, zapisaćis_internal_staffw swojej tabeli. - Periodically (cron raz na dobę) resync
is_internal_staffz DealerID. User mógł stracić uprawnienia staff (np. wyszedł z Grupy Dealer) — DealerID jest source of truth. - Mapować
is_internal_staff = TRUEna swoją wewnętrzną rolę staff/admin.
Co nie zostało zbudowane (TODO)
Dział zatytułowany „Co nie zostało zbudowane (TODO)”- Audit log kto, kiedy auto-provisionował się jako staff. Dziś tylko logujemy
'OIDC callback: konto nie istnieje'przy odrzuceniu — nie logujemy sukcesów provisioning. - Resync
is_internal_staffz Google directory — pracownik wychodzi z Grupy Dealer, ale pozostaje wapp_userzis_internal_staff = TRUE. Workspace de-provisioning powinien trigger’ować deactivation w DealerID (cron lub event). - 2FA enforcement poza Google. Workspace robi 2FA, więc wszystko OK dla internal staff. Ale dla zwykłych userów (hasło + Microsoft) nie mamy własnego 2FA w DealerID — TODO.