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 (

View File

@@ -2,6 +2,7 @@ interface AppConfig {
logtoEndpoint: string;
logtoClientId: string;
logtoResource: string;
scopes: string[];
}
let cached: AppConfig | null = null;
@@ -24,6 +25,18 @@ export async function fetchConfig(): Promise<AppConfig> {
logtoEndpoint: import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001',
logtoClientId: import.meta.env.VITE_LOGTO_CLIENT_ID || '',
logtoResource: import.meta.env.VITE_LOGTO_RESOURCE || '',
scopes: [
'platform:admin',
'tenant:manage',
'billing:manage',
'team:manage',
'apps:manage',
'apps:deploy',
'secrets:manage',
'observe:read',
'observe:debug',
'settings:manage',
],
};
return cached;
}

View File

@@ -47,6 +47,7 @@ function App() {
logtoEndpoint: string;
logtoClientId: string;
logtoResource: string;
scopes: string[];
} | null>(null);
useEffect(() => {
@@ -71,6 +72,9 @@ function App() {
'openid', 'profile', 'email', 'offline_access',
UserScope.Organizations,
UserScope.OrganizationRoles,
// API resource scopes — served from /api/config, must be requested
// during sign-in for Logto to include them in access tokens.
...(config.scopes ?? []),
],
}}
>