feat: auth hardening — scope enforcement, tenant isolation, and docs
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:
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? []),
|
||||
],
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user