feat: auth hardening — scope enforcement, tenant isolation, and docs
All checks were successful
CI / build (push) Successful in 38s
CI / docker (push) Successful in 39s

Add @PreAuthorize annotations to all API controllers (14 endpoints
across 6 controllers) enforcing OAuth2 scopes: apps:manage, apps:deploy,
billing:manage, observe:read, platform:admin.

Enforce tenant isolation: TenantResolutionFilter now rejects cross-tenant
access on /api/tenants/{id}/* paths. New TenantOwnershipValidator checks
environment/app ownership for paths without tenantId. Platform admins
bypass both layers.

Fix frontend: OrgResolver split into two useEffect hooks so scopes
refresh on org switch. Scopes now served from /api/config (single source
of truth). Bootstrap cleaned — standalone org permissions removed.

Update docs/architecture.md, docs/user-manual.md, and CLAUDE.md to
reflect all auth hardening changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 15:32:53 +02:00
parent b459a69083
commit 051f7fdae9
21 changed files with 408 additions and 136 deletions

View File

@@ -15,6 +15,7 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
const { getAccessToken } = useLogto();
const { setOrganizations, setCurrentOrg, setScopes, currentOrgId } = useOrgStore();
// Effect 1: Org population — runs when /api/me data loads
useEffect(() => {
if (!me) return;
@@ -31,22 +32,46 @@ export function OrgResolver({ children }: { children: React.ReactNode }) {
if (orgEntries.length === 1 && !currentOrgId) {
setCurrentOrg(orgEntries[0].id);
}
}, [me]);
// Read scopes from the access token JWT payload
fetchConfig().then((config) => {
// Effect 2: Scope fetching — runs when me loads OR when currentOrgId changes
useEffect(() => {
if (!me) return;
// Read scopes from access tokens:
// - org-scoped resource token → tenant-level scopes (apps:manage, observe:read, etc.)
// - global resource token → platform-level scopes (platform:admin)
fetchConfig().then(async (config) => {
if (!config.logtoResource) return;
getAccessToken(config.logtoResource).then((token) => {
if (!token) return;
const extractScopes = (token: string | undefined): string[] => {
if (!token) return [];
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const scopeStr = (payload.scope as string) ?? '';
setScopes(new Set(scopeStr.split(' ').filter(Boolean)));
return ((payload.scope as string) ?? '').split(' ').filter(Boolean);
} catch {
setScopes(new Set());
return [];
}
}).catch(() => setScopes(new Set()));
};
try {
const [orgToken, globalToken] = await Promise.all([
currentOrgId
? getAccessToken(config.logtoResource, currentOrgId).catch(() => undefined)
: Promise.resolve(undefined),
getAccessToken(config.logtoResource).catch(() => undefined),
]);
const merged = new Set([
...extractScopes(orgToken),
...extractScopes(globalToken),
]);
setScopes(merged);
} catch {
setScopes(new Set());
}
});
}, [me]);
}, [me, currentOrgId]);
if (isLoading) {
return (