diff --git a/README.md b/README.md index 072b9f1..9056cb4 100644 --- a/README.md +++ b/README.md @@ -37,27 +37,42 @@ Register **one** confidential client for the BFF (Appsmith apps never get their ### Apps (`config/services.yaml` → `appsmith.apps`) -One entry per Appsmith app: its login-return URL and the Keycloak client role required to use it. +One entry per Appsmith app: its login-return URL, the Keycloak client role required to enter it, and the prefix marking that app's permission roles. ```yaml appsmith.apps: crm: url: '%env(APP_CRM_LOGIN_URL)%' - role: 'app-crm-access' + role_prefix: 'app-crm-' + access_role: 'app-crm-access' helpdesk: url: '%env(APP_HELPDESK_LOGIN_URL)%' - role: 'app-helpdesk-access' + role_prefix: 'app-helpdesk-' + access_role: 'app-helpdesk-access' ``` Add the matching `APP__LOGIN_URL` in `.env.local`. No code changes needed. -Role setup in Keycloak: -1. On the BFF client → **Roles** → create one role per app (e.g. `app-crm-access`). -2. Create a group per app, assign the matching role under the group's **Role mapping**. +Keep prefixes non-overlapping — with `app-crm-` and `app-crm-admin-` as two apps, the first would swallow the second's roles. + +### Roles and permissions + +All roles live on the single BFF client. Two kinds, distinguished only by name: + +- **Access role** (`access_role`) — the gate. Without it, login ends in `?error=access_denied`. +- **Permission roles** — every other role sharing the app's `role_prefix`. The prefix is stripped before the app sees them, so `app-crm-write` is reported as `write`. Adding a permission is a Keycloak role creation; nothing changes here or in the code. + +Setup in Keycloak: +1. On the BFF client → **Roles** → create `app-crm-access` plus one role per permission (`app-crm-view`, `app-crm-write`, …). +2. Create a group per app (or per role bundle, e.g. `crm-editors`), assign roles under the group's **Role mapping**. 3. Put users in whichever groups they need. 4. Client → **Client scopes** → `-dedicated` → **Mappers** → confirm a "client roles" mapper exists with **Add to access token** enabled (default for new clients). -Enforcement happens once, in `AuthController::callback`, by checking `resource_access..roles` on the access token right after login. The `/api/*` proxy does not re-check roles per request. +Where this is enforced: the access role is checked once, in `AuthController::callback`, against `resource_access..roles` on the access token right after login. The `/api/*` proxy does not re-check anything per request. + +Permissions are **not** enforced by the BFF at all. `/auth/me` reports them so an app can hide or disable widgets, but anyone holding a `sid` can call the API directly. The backend is the authorization boundary and must check the roles claim on the access token the proxy injects. + +Roles are re-read from Keycloak whenever the BFF refreshes the access token (roughly once per token lifetime), so granting or revoking a permission takes effect without a re-login. The access role is deliberately *not* re-checked there — revoking it takes effect at the next login, or immediately if you kill the Keycloak session. ### Backends (`config/services.yaml` → `backends`) @@ -170,7 +185,7 @@ storeValue('sid', null); navigateTo('LoginPage', {}, 'REPLACE'); ``` -**User profile** — one query `Bff_Me`: `GET /auth/me`, header `Authorization: Bearer {{appsmith.store.sid}}`. Returns: +**User profile and permissions** — one query `Bff_Me`: `GET /auth/me?app=crm`, header `Authorization: Bearer {{appsmith.store.sid}}`. The `app` key is required (hardcode the app's own key, same value it passes to `Bff.login`); one session is shared across every app the user has open, so the BFF needs to know who's asking to scope the permissions. Returns: ```json { @@ -179,11 +194,19 @@ navigateTo('LoginPage', {}, 'REPLACE'); "name": "Jane Doe", "given_name": "Jane", "family_name": "Doe", - "preferred_username": "jdoe" + "preferred_username": "jdoe", + "app": "crm", + "permissions": ["access", "view", "write"] } ``` -Reference via `{{appsmith.store.user.email}}` etc. This is client-visible display data, not an authorization signal — the backend must still authorize off the real access token the BFF injects. +Reference via `{{appsmith.store.user.email}}` etc. Gate UI on permissions with + +```js +{{ appsmith.store.user.permissions.includes('write') }} +``` + +This is client-visible display data, not an authorization signal — the backend must still authorize off the real access token the BFF injects. ## Known limitation diff --git a/config/services.yaml b/config/services.yaml index 60faf12..3821b1e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -7,14 +7,20 @@ parameters: keycloak.redirect_uri: '%env(KEYCLOAK_REDIRECT_URI)%' keycloak.post_logout_redirect: '%env(KEYCLOAK_POST_LOGOUT_REDIRECT)%' - # Allowlist of Appsmith apps this BFF will redirect back to, each with - # the Keycloak client role (on the bff client) required to enter it. + # Allowlist of Appsmith apps this BFF will redirect back to. An app's + # permissions are the Keycloak roles on the bff client sharing its + # `role_prefix`; the prefix is stripped when they're reported to the app, + # so `app-sandbox-write` becomes "write". Adding a permission means + # creating the role in Keycloak — nothing to change here. `access_role` + # is the single role required to enter the app at all. + # Keep prefixes non-overlapping across apps, or roles bleed between them. # Add one entry per app you build. Never accept a return URL from the # request itself — always resolve through this map. appsmith.apps: app-sandbox: url: '%env(APP_SANDBOX_LOGIN_URL)%' - role: 'app-sandbox-access' + role_prefix: 'app-sandbox-' + access_role: 'app-sandbox-access' # Named backends the proxy can forward to. A request picks one via the # X-Backend header; omitting it uses "default". Add BACKEND__URL to diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 584eba0..79a494f 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Security\AccessTokenRoles; use App\Security\AppRegistry; use App\Security\IdTokenDecoder; use App\Session\BffSessionStore; @@ -20,6 +21,7 @@ class AuthController extends AbstractController private readonly BffSessionStore $store, private readonly IdTokenDecoder $idTokenDecoder, private readonly AppRegistry $apps, + private readonly AccessTokenRoles $tokenRoles, private readonly string $kcBaseUrl, private readonly string $kcRealm, private readonly string $kcClientId, @@ -81,7 +83,6 @@ class AuthController extends AbstractController $appKey = (string) $session->get('oauth_app'); $returnUrl = $this->apps->resolveReturnUrl($appKey); - $requiredRole = $this->apps->requiredRole($appKey); $response = $this->client->request( 'POST', @@ -111,8 +112,14 @@ class AuthController extends AbstractController // This is the only enforcement point — the proxy trusts any // already-established session, by design, since "may this user // use app X" is a login-time question, not a per-request one. + // + // The full role list is kept on the session so /auth/me can report + // the app's finer-grained permissions. Those are display data for + // the app's UI only; they are never re-checked per request here — + // the backend authorizes off the forwarded access token. $accessClaims = $this->idTokenDecoder->decode($tokens['access_token']); - if (!$this->hasRole($accessClaims, $requiredRole)) { + $roles = $this->tokenRoles->extract($accessClaims); + if (!in_array($this->apps->accessRole($appKey), $roles, true)) { $session->remove('pkce_verifier'); $session->remove('oauth_state'); $session->remove('oauth_app'); @@ -134,6 +141,7 @@ class AuthController extends AbstractController 'refresh_token' => $tokens['refresh_token'], 'id_token' => $tokens['id_token'], 'expires_at' => time() + (int) $tokens['expires_in'], + 'roles' => $roles, 'profile' => [ 'email' => $idClaims['email'] ?? null, 'email_verified' => $idClaims['email_verified'] ?? null, @@ -149,19 +157,20 @@ class AuthController extends AbstractController return new RedirectResponse($returnUrl . $separator . http_build_query(['sid' => $kcSid])); } - /** @param array $accessTokenClaims */ - private function hasRole(array $accessTokenClaims, string $role): bool - { - $roles = $accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? []; - - return in_array($role, $roles, true); - } - /** - * Returns the logged-in user's profile (email, name, etc). Called by - * an app as a normal API query with `Authorization: Bearer ` — - * same pattern as /api/*, but served directly by the BFF since this - * data comes from the ID token, not the backend. + * Returns the logged-in user's profile (email, name, etc) plus the + * permissions they hold for the calling app. Called by an app as a + * normal API query with `Authorization: Bearer ` — same pattern as + * /api/*, but served directly by the BFF since this data comes from the + * tokens, not the backend. + * + * The `app` query parameter is required: one session is shared by every + * app the user opens, so the BFF can only scope permissions if the + * caller says which app is asking. + * + * Permissions here are for the app's own UI gating. They are not an + * authorization boundary — anything that matters must be enforced by + * the backend off the access token the proxy injects. */ #[Route('/auth/me', methods: ['GET'])] public function me(Request $request): JsonResponse @@ -171,12 +180,20 @@ class AuthController extends AbstractController return new JsonResponse(['error' => 'unauthenticated'], 401); } + $appKey = (string) $request->query->get('app', ''); + if (!$this->apps->isValidApp($appKey)) { + return new JsonResponse(['error' => 'unknown or missing app key'], 400); + } + $session = $this->store->get($kcSid); if ($session === null) { return new JsonResponse(['error' => 'session expired'], 401); } - return new JsonResponse($session['profile'] ?? []); + return new JsonResponse(($session['profile'] ?? []) + [ + 'app' => $appKey, + 'permissions' => $this->apps->permissions($appKey, $session['roles'] ?? []), + ]); } /** diff --git a/src/Security/AccessTokenRoles.php b/src/Security/AccessTokenRoles.php new file mode 100644 index 0000000..93ad493 --- /dev/null +++ b/src/Security/AccessTokenRoles.php @@ -0,0 +1,25 @@ + $accessTokenClaims decoded (and signature-verified) access token + * + * @return list + */ + public function extract(array $accessTokenClaims): array + { + return array_values($accessTokenClaims['resource_access'][$this->kcClientId]['roles'] ?? []); + } +} diff --git a/src/Security/AppRegistry.php b/src/Security/AppRegistry.php index 77b848e..fa2a264 100644 --- a/src/Security/AppRegistry.php +++ b/src/Security/AppRegistry.php @@ -4,14 +4,18 @@ namespace App\Security; /** * Allowlist of Appsmith apps this BFF will redirect back to after login, - * each paired with the Keycloak client role required to access it. + * each paired with the Keycloak client role required to access it and the + * prefix identifying that app's permission roles. * * Never redirect to an arbitrary caller-supplied URL — always resolve * through this registry to prevent open-redirect abuse. */ class AppRegistry { - /** @param array $apps app key => ['url' => ..., 'role' => ...] */ + /** + * @param array $apps + * app key => ['url' => ..., 'role_prefix' => ..., 'access_role' => ...] + */ public function __construct(private readonly array $apps) { } @@ -26,12 +30,37 @@ class AppRegistry return $this->requireApp($appKey)['url']; } - public function requiredRole(string $appKey): string + public function accessRole(string $appKey): string { - return $this->requireApp($appKey)['role']; + return $this->requireApp($appKey)['access_role']; } - /** @return array{url:string,role:string} */ + /** + * Narrows every role the user holds on the bff client down to the ones + * belonging to this app, reported without the app's prefix. The access + * role shares the prefix, so it shows up here as "access" — intentional, + * the prefix has no exceptions and apps are free to ignore it. + * + * @param list $clientRoles every bff-client role the user holds + * + * @return list this app's permissions, prefix stripped + */ + public function permissions(string $appKey, array $clientRoles): array + { + $prefix = $this->requireApp($appKey)['role_prefix']; + + $permissions = []; + foreach ($clientRoles as $role) { + if (str_starts_with($role, $prefix)) { + $permissions[] = substr($role, strlen($prefix)); + } + } + sort($permissions); + + return $permissions; + } + + /** @return array{url:string,role_prefix:string,access_role:string} */ private function requireApp(string $appKey): array { if (!isset($this->apps[$appKey])) { diff --git a/src/Session/TokenRefresher.php b/src/Session/TokenRefresher.php index 8eabc39..54dfa8d 100644 --- a/src/Session/TokenRefresher.php +++ b/src/Session/TokenRefresher.php @@ -2,6 +2,8 @@ namespace App\Session; +use App\Security\AccessTokenRoles; +use App\Security\IdTokenDecoder; use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -12,6 +14,8 @@ class TokenRefresher public function __construct( private readonly HttpClientInterface $client, private readonly BffSessionStore $store, + private readonly IdTokenDecoder $idTokenDecoder, + private readonly AccessTokenRoles $tokenRoles, private readonly LoggerInterface $logger, private readonly string $kcBaseUrl, private readonly string $kcRealm, @@ -64,6 +68,21 @@ class TokenRefresher $session['refresh_token'] = $tokens['refresh_token'] ?? $session['refresh_token']; $session['expires_at'] = time() + $tokens['expires_in']; + // Re-read the roles so /auth/me reflects permissions granted or + // revoked in Keycloak mid-session. The access gate itself is not + // re-evaluated here — "may this user use app X" stays a login-time + // question, so losing the access role won't kill a live session. + try { + $session['roles'] = $this->tokenRoles->extract( + $this->idTokenDecoder->decode($tokens['access_token']) + ); + } catch (\Throwable $e) { + // Keep the previous list — a decode hiccup must not drop the session. + $this->logger->warning('Could not re-read roles from refreshed access token', [ + 'exception' => $e, + ]); + } + $this->store->put($kcSid, $session); return $session['access_token'];