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 (
|
||||
|
||||
Reference in New Issue
Block a user