App Directory — implementacja po stronie aplikacji
Ta strona to przewodnik z perspektywy aplikacji partnerskiej — czyli ciebie, jeśli budujesz
BMS, Service, Analytics albo cokolwiek nowego. Zakłada, że twoja apka jest już zarejestrowana
jako oauth2_client w DealerID (patrz Rejestracja aplikacji)
i ma walidację Bearer JWT w API (jak CRM JwtAuthenticator).
Co masz zbudować
Dział zatytułowany „Co masz zbudować”Endpoint:
GET /api/dealerid/user-appsAuthorization: Bearer <JWT mintowany przez DealerID, scope app.directory.read>Response: lista kart {name, host, role, status, subtitle?, lastSeen?, brandingSkinSlug?}.
Pełny kontrakt: App Directory — kontrakt.
Krok 1 — bypass walidatora aplikacji (jeśli używasz)
Dział zatytułowany „Krok 1 — bypass walidatora aplikacji (jeśli używasz)”W większości naszych apek mamy walidator, który resolve’uje aktualną Application z Host headera
(patrz Multi-tenancy: Aplikacje) i sprawdza membership. DealerID
woła twój endpoint bez konkretnego hosta tenanta — chce enumeracja wszystkich Applications dla
danego usera. Dodaj prefix /api/dealerid do exempt-list:
private const array EXEMPT_PATHS = [ '/api/me', '/api/docs', '/api/public', '/api/dealerid', // ← server-to-server discovery (bez application context)];Historyczna uwaga: dawniej apki używały headera
X-Organization-Idprzy każdym requeście — zwolnienie było analogiczne. Teraz, gdy application context wyciągany jest zHost(a nie z klienckiego headera), motywacja zwolnienia jest taka sama: dla discovery callu nie ma jednej konkretnej Application.
Krok 2 — handler
Dział zatytułowany „Krok 2 — handler”Pseudokod w Symfony / PHP (czyli to, co mamy w CRM). Endpoint /api/dealerid/user-apps zwraca
per-Application listing:
- Dla staff (
is_internal_staff = TRUE) → wszystkie aktywne Applications (wszystkie kindy, wszystkie organizacje). - Dla regular usera → Applications, do których user ma dostęp przez membership w
application.organization_id, z wyłączeniem kind=staff(staff Application jest tylko dla pracowników platformy).
namespace App\Module\Iam\Ui\Http;
use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\Routing\Attribute\Route;
final class UserAppsController{ public function __construct( private readonly CurrentUserProvider $currentUserProvider, private readonly MembershipFinder $membershipFinder, // Application = instancja CRM pod konkretnym hostem // (patrz /multitenancy/applications/): private readonly ApplicationFinder $applicationFinder, private readonly OrganizationFinder $organizationFinder, ) {}
#[Route('/api/dealerid/user-apps', name: 'app_directory', methods: ['GET'])] public function __invoke(): JsonResponse { $user = $this->currentUserProvider->current(); if (null === $user) { // Bearer token nie zwalidował się — security firewall powinien już to złapać. return new JsonResponse(['type' => '/errors/401', 'title' => 'Unauthorized'], 401); }
$entries = [];
if ($user->isStaff) { // Staff Grupy Dealer widzi wszystkie aktywne Applications (bypass membership). // Wszystkie kindy, wszystkie organizacje, wszystkie hosty. foreach ($this->applicationFinder->list(active: true) as $app) { $entries[] = $this->renderEntry( name: $this->displayName($app), host: $app->host, role: 'Staff', brandingSkinSlug: $this->resolveBrandingSkin($app), ); } } else { // Regular user — membership-driven listing. foreach ($user->memberships as $m) { if ('active' !== $m->status) continue;
// Każda Application, której organization_id == this membership. // Dla dealer_main/importer_main → 1 wpis (main host). // Dla importer_network → N wpisów (po jednym na każdą sieć importera, w której dealer jest). $apps = $this->applicationFinder->listForOrganization( organizationId: $m->organizationId, active: true, ); foreach ($apps as $app) { if ('staff' === $app->kind) continue; // staff Application nie dla regular userów
$entries[] = $this->renderEntry( name: $this->displayName($app), host: $app->host, role: $this->primaryRoleName($m->roleIds), brandingSkinSlug: $this->resolveBrandingSkin($app), ); } } }
return new JsonResponse(['apps' => $entries]); }
private function resolveBrandingSkin(ApplicationView $app): ?string { // Branding skin jest na organization.branding_skin_slug — Application dziedziczy: // - importer_network → z organization wskazanej przez importer_id // - pozostałe kindy → z organization_id $orgId = 'importer_network' === $app->kind ? $app->importerId : $app->organizationId; return $this->organizationFinder->findById($orgId)?->brandingSkinSlug; }
private function renderEntry(string $name, string $host, string $role, ?string $brandingSkinSlug): array { return [ 'name' => $name, 'host' => $host, 'role' => $role, 'status' => 'active', 'subtitle' => null, 'lastSeen' => null, 'brandingSkinSlug' => $brandingSkinSlug, ]; }}Krok 3 — auth na endpoincie
Dział zatytułowany „Krok 3 — auth na endpoincie”Twój JwtAuthenticator powinien:
- Sparsować Bearer JWT.
- Zweryfikować RS256 publicznym kluczem DealerID (
OAUTH_PUBLIC_KEY). - Zweryfikować
nbf/iat/exp. - Wyciągnąć
sub(email — bo DealerID podpisuje JWT zrelatedTo($userEmail)wHttpAppDirectoryFetcher::mintToken()). - Wyciągnąć
scopes— sprawdzić, że jestapp.directory.read. - Lazy provision lokalnego usera po emailu (jeśli pierwszy login).
CRM przykład pokazuje też istotny detal — skip /userinfo dla tokenów bez openid:
$isInternalStaff = \in_array('openid', $scopes, true) ? $this->fetchIsInternalStaff($rawToken) : null;$this->commandBus->dispatch(new ProvisionUserIfMissing($sub, $isInternalStaff));JWT mintowany przez HttpAppDirectoryFetcher ma tylko app.directory.read, więc skipniemy /userinfo.
is_internal_staff zostaje na ostatniej znanej wartości w DB (lub null jeśli pierwszy raz).
Jest to OK — to discovery endpoint, nie pierwszy login.
Krok 4 — zaktualizuj oauth2_client.app_directory_url
Dział zatytułowany „Krok 4 — zaktualizuj oauth2_client.app_directory_url”Po wystawieniu endpointu i wdrożeniu apki, daj DealerID znać, gdzie go szukać. Na razie ręcznie:
-- w DealerID DB:UPDATE oauth2_clientSET app_directory_url = 'http://bms-nginx/api/dealerid/user-apps'WHERE identifier = 'bms-frontend';URL musi być osiągalny z perspektywy sieci DealerID:
- dev (docker-compose): nazwa service’u (
bms-nginx) — Docker DNS rozwiązuje to do kontenera. Patrz CRM przykład:http://crm-nginx/api/dealerid/user-apps(migracjaVersion20260520120000). - prod: wewnętrzny URL (VPN, service mesh, private DNS). Nigdy publiczny
https://…dealercrm.pl, bo to dodatkowy hop + ekspozycja na świat.
Przyszłość: admin UI w DealerID pozwoli to ustawić bez SQL. Na razie SQL
UPDATE.
Krok 5 — przetestuj end-to-end
Dział zatytułowany „Krok 5 — przetestuj end-to-end”- Zaloguj się jako user, który ma membership w twojej apce.
- Przejdź na
http://localhost:8082/account/apps(DealerID). - Sprawdź, czy twoja karta się pokazuje.
- Sprawdź logi DealerID (
docker compose logs -f dealerid-php) — powinieneś zobaczyć:AppDirectoryFetcher: request creation failed⇒ network problem (nie ma DNS / kontener nie żyje).AppDirectoryFetcher: non-200 from app⇒ twój endpoint zwrócił 4xx/5xx.AppDirectoryFetcher: response failed⇒ timeout (>2s) / connection reset.
Jeśli twoja karta się nie pokazuje, ale błędów nie ma — twoja odpowiedź pewnie ma pustą listę
apps (== brak membership dla tego usera). To nie błąd, to normalna sytuacja.
Edge cases
Dział zatytułowany „Edge cases”User nie istnieje w twojej apce
Dział zatytułowany „User nie istnieje w twojej apce”DealerID mintuje JWT z sub = userEmail zawsze, niezależnie od tego, czy user jest w twojej apce.
Twój authenticator powinien lazy-provisionować (jak ProvisionUserIfMissing w CRM) i zwrócić
pustą listę jeśli user nie ma żadnego membership.
Provisioning niespodziewanie podchodzi pod każdy discovery call
Dział zatytułowany „Provisioning niespodziewanie podchodzi pod każdy discovery call”To OK, bo provisioning jest idempotent (insert-if-missing po email). Jeśli wolisz uniknąć provisioning przy discovery calls (np. dla apek z drogim onboarding userów), możesz odrzucić discovery dla nieznanych emailów:
if (\in_array('app.directory.read', $scopes, true) && !$user) { return new JsonResponse(['apps' => []]);}Brand-scoped Applications (jak w CRM)
Dział zatytułowany „Brand-scoped Applications (jak w CRM)”Jeśli twoja apka ma model application z kind=importer_network (brand-scoped host, np.
gezet-renault.localhost), zwracaj każdą aktywną Application osobno jako wpis. User z 1
membership w dealerze może mieć N kart, jeśli ma N Applications (1 dealer_main + N importer_network
po jednym na każdą sieć importera). Patrz Multi-tenancy: Aplikacje.
Timeout 2s
Dział zatytułowany „Timeout 2s”DealerID czeka maksymalnie 2 sekundy na twoją odpowiedź. Jeśli twoje /api/dealerid/user-apps
robi 30 queries po memberships + applications + caching is cold — masz problem. Optymalizuj:
- Jeden SQL z JOIN-em (memberships × organizations × applications).
- Cache per user (Redis, TTL 60s).
- Index na
(user_id, status)i(organization_id, active)w tabeliapplication.
Realistycznie powinno być < 50ms.
Diagram — twoja apka w sieci DealerID
Dział zatytułowany „Diagram — twoja apka w sieci DealerID”flowchart LR
subgraph DID["DealerID (id.dealercrm.pl)"]
Account["GET /account/apps"]
Mint["Mint JWT<br/>RS256, 5min,<br/>sub=email,<br/>scopes=app.directory.read"]
end
subgraph YOUR["Twoja apka (np. BMS)"]
Edge["bms-nginx<br/>internal DNS"]
Auth["JwtAuthenticator<br/>(RS256 verify)"]
Provision["ProvisionUserIfMissing<br/>(lazy)"]
Handler["UserAppsController<br/>memberships × applications"]
DB[("bms-db<br/>users, memberships, applications")]
end
Account --> Mint
Mint --> Edge
Edge --> Auth
Auth -->|"valid sig + scope app.directory.read"| Provision
Provision --> DB
Handler --> DB
Provision --> Handler
Handler -->|"{ apps: [...] }"| Edge
Edge --> Account
Checklist przed merge
Dział zatytułowany „Checklist przed merge”- Endpoint
/api/dealerid/user-appszwraca 200 z poprawnym JSONem. - Bearer JWT walidacja działa (test: nieważny token → 401).
- Lazy provisioning działa (test: świeży email → pierwsze wywołanie tworzy lokalnego usera).
- Endpoint jest w
EXEMPT_PATHSapplication validatora (jeśli takiego używasz). -
oauth2_client.app_directory_urlzaktualizowany w bazie DealerID (PR migracja albo manual SQL). - Wpis w
APP_PRESENTATIONpo stronie DealerID (HttpAppDirectoryFetcher.php) — PR do DealerID. - Logi po stronie DealerID są czyste (brak
AppDirectoryFetcher: non-200). - Smoke test: zalogowany user widzi karty twojej apki na
/account/apps.