Przejdź do głównej zawartości

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

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

Endpoint:

GET /api/dealerid/user-apps
Authorization: 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:

app/src/Shared/Security/YourApplicationAccessValidator.php
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-Id przy każdym requeście — zwolnienie było analogiczne. Teraz, gdy application context wyciągany jest z Host (a nie z klienckiego headera), motywacja zwolnienia jest taka sama: dla discovery callu nie ma jednej konkretnej Application.

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,
];
}
}

Twój JwtAuthenticator powinien:

  1. Sparsować Bearer JWT.
  2. Zweryfikować RS256 publicznym kluczem DealerID (OAUTH_PUBLIC_KEY).
  3. Zweryfikować nbf/iat/exp.
  4. Wyciągnąć sub (email — bo DealerID podpisuje JWT z relatedTo($userEmail) w HttpAppDirectoryFetcher::mintToken()).
  5. Wyciągnąć scopes — sprawdzić, że jest app.directory.read.
  6. 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_client
SET 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 (migracja Version20260520120000).
  • 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.

  1. Zaloguj się jako user, który ma membership w twojej apce.
  2. Przejdź na http://localhost:8082/account/apps (DealerID).
  3. Sprawdź, czy twoja karta się pokazuje.
  4. 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.

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' => []]);
}

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.

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 tabeli application.

Realistycznie powinno być < 50ms.

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
  • Endpoint /api/dealerid/user-apps zwraca 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_PATHS application validatora (jeśli takiego używasz).
  • oauth2_client.app_directory_url zaktualizowany w bazie DealerID (PR migracja albo manual SQL).
  • Wpis w APP_PRESENTATION po 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.