Przejdź do głównej zawartości

Wyszukiwanie jest dostępne tylko w buildach produkcyjnych. Spróbuj zbudować i uruchomić aplikację, aby przetestować lokalnie.

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 = TRUE propaguje się do downstream apek przez claim /userinfo, gdzie apki mogą auto-grantować staff w swojej lokalnej tabeli membership (CRM: iam_user.is_staff = TRUE).
  1. 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.
  2. Brak shadow IT. Pracownik nie może „obejść” Workspace’a logując się hasłem do DealerID.
  3. 2FA + audit log na poziomie Google. Wymuszanie 2FA, conditional access, logi — wszystko żyje w Workspace, nie reinwentujemy.
  4. 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.

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.

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)

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?ProviderDomena ∈ internalWynik
Nie istniejeGoogleTAKAuto-provision z is_internal_staff = TRUE, login OK.
Nie istniejeGoogleNIESsoUserNotFound — komunikat „Konto nie istnieje, poproś administratora”.
Nie istniejeMSdowolnieSsoUserNotFound. Microsoft nie auto-provisionuje internal staff (rozmyślnie).
Istnieje + is_internal_staffGoogledowolnieLogin OK.
Istnieje + is_internal_staffMSdowolnieInternalStaffMustUseGoogle — „To konto jest skonfigurowane do Google Workspace”.
Istnieje, nie staffGoogle / MS / hasłodowolnieLogin OK (zwykły user).

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.php
  • security.yaml: user_checker: App\Module\User\Infrastructure\Security\InternalStaffPasswordLoginBlocker

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.

Dwiema drogami:

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));

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):

apps/crm-api/src/Module/Iam/Infrastructure/Security/JwtAuthenticator.php
$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:

  1. Lazy-provision lokalnego usera (insert-if-missing po sub/email).
  2. Przy pierwszym loginie wołać /userinfo raz, zapisać is_internal_staff w swojej tabeli.
  3. Periodically (cron raz na dobę) resync is_internal_staff z DealerID. User mógł stracić uprawnienia staff (np. wyszedł z Grupy Dealer) — DealerID jest source of truth.
  4. Mapować is_internal_staff = TRUE na swoją wewnętrzną rolę staff/admin.
  • 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_staff z Google directory — pracownik wychodzi z Grupy Dealer, ale pozostaje w app_user z is_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.