Files
bface/src/security/policy/ApiSecurityPolicy.js
Amer Agovic 859db6ccb2 Release 1.0.8 with platform, security, and UI hardening.
Adds API filter registry, style theme registry, SW bitmask cache clear, KV namespacing, session expiry checks, accessibility improvements, and expanded test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 21:08:21 -05:00

636 lines
21 KiB
JavaScript

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 || '/');
}
}