Update bface UI and security work

This commit is contained in:
Amer Agovic
2026-05-31 12:30:02 -05:00
parent 6fe23fae86
commit c6f7240912
45 changed files with 4531 additions and 553 deletions
+558
View File
@@ -0,0 +1,558 @@
import { getProvider } from '../../platform/storage.js';
import { api } from '../../platform/api.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;
return this.client.requestJSON(path, {
...options,
trackActivity,
headers: {
...(options.headers || {}),
...(authToken ? { Authorization: `Bearer ${authToken}` } : {})
}
});
}
_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 || '';
const basicToken = typeof btoa === 'function'
? btoa(`${username}:${password}`)
: Buffer.from(`${username}:${password}`, 'utf-8').toString('base64');
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 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();
}
_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 matchingPermits = this.cache.permits.filter((permit) => 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 || '/');
}
}