import { getProvider } from '../../platform/storage.js'; import { api } from '../../platform/api.js'; import { SECURITY_REQUEST_FILTER } from '../runtime/api-auth.js'; import { SecurityPolicy } from './SecurityPolicy.js'; import { AccountProfile, Permit, Realm, Resource, Role, Securable, Session, User, hasRequiredRights, normalizeRightsInput } from '../model/index.js'; const SESSION_KEY = 'security.api.session'; function clone(value) { return value == null ? value : JSON.parse(JSON.stringify(value)); } function pathMatches(resourcePath, targetPath) { if (!resourcePath || resourcePath === '*') { return true; } if (resourcePath.endsWith('*')) { return targetPath.startsWith(resourcePath.slice(0, -1)); } return targetPath === resourcePath || targetPath.startsWith(`${resourcePath}/`); } export class ApiSecurityPolicy extends SecurityPolicy { constructor(config = {}) { super(config); this.storage = getProvider('kv', 'security.api'); this.baseURL = config.base_url || '/api/security'; this.client = api.scope(this.baseURL); this.cache = { session: null, user: null, profile: null, realm: null, resources: [], permits: [], subjects: [], admin: null, adminPromise: null }; } async init() { await this._hydrateCurrentSession(); } async _request(path, options = {}, extra = {}) { const { authToken = null, trackActivity = true } = extra; const headers = new Headers(options.headers || {}); if (authToken) { headers.set('Authorization', `Bearer ${authToken}`); } return this.client.requestJSON(path, { ...options, trackActivity, skipRequestFilters: [SECURITY_REQUEST_FILTER], headers }); } async getRequestAuthorization(_requestContext, { session } = {}) { const token = session?.jwt_token || this.cache.session?.jwt_token || null; if (!token) { return null; } return { scheme: 'Bearer', token }; } _applyBundle(bundle = {}) { this.cache.session = bundle.session ? new Session(bundle.session) : null; this.cache.user = bundle.user ? new User(bundle.user) : null; this.cache.profile = bundle.profile ? new AccountProfile(bundle.profile) : null; this.cache.realm = bundle.realm ? new Realm(bundle.realm) : null; this.cache.resources = Array.isArray(bundle.resources) ? bundle.resources.map((item) => new Resource(item)) : []; this.cache.permits = Array.isArray(bundle.permits) ? bundle.permits.map((item) => new Permit(item)) : []; } async _hydrateCurrentSession() { const stored = await this.storage.get(SESSION_KEY, null); if (!stored?.jwt_token) { await this.clearSession(); return null; } try { const bundle = await this._request('/session', { method: 'GET' }, { authToken: stored.jwt_token, trackActivity: false }); this._applyBundle(bundle); await this.saveSession(this.cache.session); return this.cache.session; } catch (error) { await this.clearSession(); if (error?.status === 401) { return null; } throw error; } } async authenticate(credentials = {}) { const username = credentials.username || credentials.email || ''; const password = credentials.password || ''; if (typeof btoa !== 'function') { throw new Error('Basic authentication requires btoa in the current runtime'); } const basicToken = btoa(`${username}:${password}`); const bundle = await this._request('/login', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Basic ${basicToken}` }, body: JSON.stringify({ deterministic: Boolean(credentials.deterministic), products: Array.isArray(credentials.products) ? credentials.products : [], licence_key: credentials.licence_key || null, dimensions: Array.isArray(credentials.dimensions) ? credentials.dimensions : [] }) }); this._applyBundle(bundle); await this.saveSession(this.cache.session); this.cache.admin = null; this.cache.adminPromise = null; return { session: clone(this.cache.session), user: clone(this.cache.user), profile: clone(this.cache.profile) }; } async logout(session) { const token = session?.jwt_token || this.cache.session?.jwt_token || null; if (!token) { return; } try { await this._request('/logout', { method: 'POST' }, { authToken: token }); } catch (error) { if (error?.status !== 401) { throw error; } } } async getCurrentSession() { if (this.cache.session?.jwt_token) { return clone(this.cache.session); } const session = await this._hydrateCurrentSession(); return clone(session); } async saveSession(session) { if (!session) { await this.clearSession(); return; } await this.storage.set(SESSION_KEY, clone(session)); } async clearSession() { this.cache = { session: null, user: null, profile: null, realm: null, resources: [], permits: [], subjects: [], admin: null, adminPromise: null }; await this.storage.remove(SESSION_KEY); } async _ensureSessionLoaded() { if (this.cache.session?.jwt_token && this.cache.user?.id) { return this.cache.session; } return this._hydrateCurrentSession(); } async _loadAdminDataset() { await this._ensureSessionLoaded(); if (!this.cache.session?.jwt_token) { return null; } if (this.cache.admin) { return this.cache.admin; } if (this.cache.adminPromise) { return this.cache.adminPromise; } const token = this.cache.session.jwt_token; this.cache.adminPromise = (async () => { const [users, roles, realms, resources, subjects, permits] = await Promise.all([ this._request('/admin/users', { method: 'GET' }, { authToken: token }), this._request('/admin/roles', { method: 'GET' }, { authToken: token }), this._request('/admin/realms', { method: 'GET' }, { authToken: token }), this._request('/admin/resources', { method: 'GET' }, { authToken: token }), this._request('/admin/subjects', { method: 'GET' }, { authToken: token }), this._request('/admin/permits', { method: 'GET' }, { authToken: token }) ]); this.cache.admin = { users: Array.isArray(users) ? users.map((item) => new User(item)) : [], roles: Array.isArray(roles) ? roles.map((item) => new Role(item)) : [], realms: Array.isArray(realms) ? realms.map((item) => new Realm(item)) : [], resources: Array.isArray(resources) ? resources.map((item) => new Resource(item)) : [], subjects: Array.isArray(subjects) ? subjects.map((item) => new Securable(item)) : [], permits: Array.isArray(permits) ? permits.map((item) => new Permit(item)) : [] }; return this.cache.admin; })(); try { return await this.cache.adminPromise; } finally { this.cache.adminPromise = null; } } async listUsers() { const admin = await this._loadAdminDataset(); return clone(admin?.users || []); } async getUser(userId) { await this._ensureSessionLoaded(); if (this.cache.user?.id === userId) { return clone(this.cache.user); } const admin = await this._loadAdminDataset(); return clone(admin?.users?.find((item) => item.id === userId) || null); } async listRoles() { const admin = await this._loadAdminDataset(); return clone(admin?.roles || []); } async listSubjects() { const admin = await this._loadAdminDataset(); return clone(admin?.subjects || []); } async listRealms() { const admin = await this._loadAdminDataset(); return clone(admin?.realms || []); } async getRealm(realmId) { await this._ensureSessionLoaded(); if (this.cache.realm?.id === realmId) { return clone(this.cache.realm); } const admin = await this._loadAdminDataset(); return clone(admin?.realms?.find((item) => item.id === realmId) || null); } _invalidateAdminCache() { this.cache.admin = null; this.cache.adminPromise = null; } async createUser(userData) { await this._ensureSessionLoaded(); const created = await this._request('/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userData) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return created ? new User(created) : null; } async updateUser(userId, patch) { await this._ensureSessionLoaded(); const updated = await this._request(`/admin/users/${encodeURIComponent(userId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return updated ? new User(updated) : null; } async deleteUser(userId) { await this._ensureSessionLoaded(); await this._request(`/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); } async getRole(roleId) { const admin = await this._loadAdminDataset(); return clone(admin?.roles?.find((item) => item.id === roleId) || null); } async createRole(roleData) { await this._ensureSessionLoaded(); const created = await this._request('/admin/roles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(roleData) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return created ? new Role(created) : null; } async updateRole(roleId, patch) { await this._ensureSessionLoaded(); const updated = await this._request(`/admin/roles/${encodeURIComponent(roleId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return updated ? new Role(updated) : null; } async deleteRole(roleId) { await this._ensureSessionLoaded(); await this._request(`/admin/roles/${encodeURIComponent(roleId)}`, { method: 'DELETE' }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); } async createRealm(realmData) { await this._ensureSessionLoaded(); const created = await this._request('/admin/realms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(realmData) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return created ? new Realm(created) : null; } async updateRealm(realmId, patch) { await this._ensureSessionLoaded(); const updated = await this._request(`/admin/realms/${encodeURIComponent(realmId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return updated ? new Realm(updated) : null; } async deleteRealm(realmId) { await this._ensureSessionLoaded(); await this._request(`/admin/realms/${encodeURIComponent(realmId)}`, { method: 'DELETE' }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); } async registerResource(resource) { await this._ensureSessionLoaded(); const created = await this._request('/admin/resources', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resource) }, { authToken: this.cache.session?.jwt_token, trackActivity: false }); this._invalidateAdminCache(); return created ? new Resource(created) : null; } async updateResource(path, patch) { await this._ensureSessionLoaded(); const normalized = String(path || '').replace(/^\/+/, ''); const updated = await this._request(`/admin/resources/${normalized}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return updated ? new Resource(updated) : null; } async deleteResource(path) { await this._ensureSessionLoaded(); const normalized = String(path || '').replace(/^\/+/, ''); await this._request(`/admin/resources/${normalized}`, { method: 'DELETE' }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); } async listResources() { const admin = await this._loadAdminDataset(); if (admin?.resources?.length) { return clone(admin.resources); } await this._ensureSessionLoaded(); return clone(this.cache.resources); } async listPermits() { const admin = await this._loadAdminDataset(); if (admin?.permits?.length) { return clone(admin.permits); } await this._ensureSessionLoaded(); return clone(this.cache.permits); } async getAccountProfile(userId) { await this._ensureSessionLoaded(); if (this.cache.profile && this.cache.profile.user_id === userId) { return clone(this.cache.profile); } return null; } async updateAccountProfile(userId, patch) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Profile update is only available for the authenticated user'); } const profile = await this._request('/account/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token }); this.cache.profile = profile ? new AccountProfile(profile) : null; if (this.cache.user) { if (patch.display_name !== undefined) this.cache.user.display_name = patch.display_name; if (patch.email !== undefined) this.cache.user.email = patch.email; if (patch.image_url !== undefined) this.cache.user.image_url = patch.image_url; } return clone(this.cache.profile); } async uploadAccountAvatar(userId, file) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Avatar upload is only available for the authenticated user'); } const form = new FormData(); form.append('file', file); const profile = await this._request('/account/avatar', { method: 'POST', body: form }, { authToken: this.cache.session?.jwt_token }); this.cache.profile = profile ? new AccountProfile(profile) : null; if (this.cache.user && profile?.image_url !== undefined) { this.cache.user.image_url = profile.image_url; } return clone(this.cache.profile); } async changePassword(userId, passwordInput = {}) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Password change is only available for the authenticated user'); } await this._request('/account/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(passwordInput) }, { authToken: this.cache.session?.jwt_token }); } async listAccountSettings(userId) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Account settings are only available for the authenticated user'); } const rows = await this._request('/account/settings', { method: 'GET' }, { authToken: this.cache.session?.jwt_token }); return Array.isArray(rows) ? clone(rows) : []; } async getAccountSetting(userId, key) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Account settings are only available for the authenticated user'); } return clone(await this._request(`/account/settings/${encodeURIComponent(key)}`, { method: 'GET' }, { authToken: this.cache.session?.jwt_token })); } async updateAccountSetting(userId, key, patch = {}) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Account settings are only available for the authenticated user'); } return clone(await this._request(`/account/settings/${encodeURIComponent(key)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token })); } async deleteAccountSetting(userId, key) { await this._ensureSessionLoaded(); if (!this.cache.user || this.cache.user.id !== userId) { throw new Error('Account settings are only available for the authenticated user'); } return this._request(`/account/settings/${encodeURIComponent(key)}`, { method: 'DELETE' }, { authToken: this.cache.session?.jwt_token }); } async grantPermit(permitData) { await this._ensureSessionLoaded(); const created = await this._request('/admin/permits', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(permitData) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return created ? new Permit(created) : null; } async updatePermit(permitId, patch) { await this._ensureSessionLoaded(); const updated = await this._request(`/admin/permits/${encodeURIComponent(permitId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); return updated ? new Permit(updated) : null; } async revokePermit(permitId) { await this._ensureSessionLoaded(); await this._request(`/admin/permits/${encodeURIComponent(permitId)}`, { method: 'DELETE' }, { authToken: this.cache.session?.jwt_token }); this._invalidateAdminCache(); } _buildPrincipals(user) { if (!user?.id) { return []; } const principals = [{ principal_type: 'user', principal_id: user.id }]; if (Array.isArray(user.role_ids)) { user.role_ids.forEach((roleId) => { principals.push({ principal_type: 'role', principal_id: roleId }); }); } return principals; } _evaluateCached(rights, resourcePath) { if (!this.cache.user?.id || !this.cache.session?.jwt_token) { return { allowed: false, requires_login: true, reason: 'Authentication required', matched_permits: [] }; } const requestedRights = normalizeRightsInput(rights); const targetPath = resourcePath || '/'; const principals = this._buildPrincipals(this.cache.user); const matchingPermits = this.cache.permits.filter((permit) => { const principalMatch = principals.some((principal) => ( principal.principal_type === permit.principal_type && principal.principal_id === permit.principal_id )); return principalMatch && pathMatches(permit.resource_path, targetPath); }); const denyMatch = matchingPermits.find((permit) => permit.effect === 'deny' && hasRequiredRights(permit.rights, requestedRights)); if (denyMatch) { return { allowed: false, requires_login: false, reason: 'Denied by explicit policy', matched_permits: clone([denyMatch]) }; } const allowMatches = matchingPermits.filter((permit) => permit.effect === 'allow' && hasRequiredRights(permit.rights, requestedRights)); if (allowMatches.length > 0 || requestedRights === 0) { return { allowed: true, requires_login: false, reason: allowMatches.length > 0 ? 'Permit granted' : 'No specific rights requested', matched_permits: clone(allowMatches) }; } return { allowed: false, requires_login: false, reason: `Missing required rights on ${targetPath}`, matched_permits: clone(matchingPermits) }; } async evaluate(userId, rights, resourcePath, context = {}) { await this._ensureSessionLoaded(); if (!this.cache.user?.id || this.cache.user.id !== userId) { return { allowed: false, requires_login: true, reason: 'Authentication required', matched_permits: [] }; } return this._evaluateCached(rights, resourcePath || context.resource_path || '/'); } evaluateSync(userId, rights, resourcePath, context = {}) { if (!this.cache.user?.id || this.cache.user.id !== userId) { return { allowed: false, requires_login: true, reason: 'Authentication required', matched_permits: [] }; } return this._evaluateCached(rights, resourcePath || context.resource_path || '/'); } }