Update bface UI and security work
This commit is contained in:
@@ -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 || '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user