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
+81
View File
@@ -0,0 +1,81 @@
function lowerText(value) {
return String(value || '').trim().toLowerCase();
}
function arrayOfLower(values) {
return Array.isArray(values) ? values.map(lowerText).filter(Boolean) : [];
}
export function getSessionClaims(securityState = {}) {
return securityState?.session?.claims || {};
}
export function getUserRoles(securityState = {}) {
const userRoles = arrayOfLower(securityState?.user?.role_names);
const claimRoles = arrayOfLower(getSessionClaims(securityState).roles);
return Array.from(new Set([...userRoles, ...claimRoles]));
}
export function isAdminUser(securityState = {}) {
return Boolean(securityState?.user?.is_admin || getSessionClaims(securityState).is_admin);
}
export function hasRequiredProducts(securityState = {}, requiredProducts = []) {
if (!Array.isArray(requiredProducts) || requiredProducts.length === 0) {
return true;
}
const granted = arrayOfLower(getSessionClaims(securityState).products);
if (granted.length === 0) {
return false;
}
return requiredProducts.some((product) => granted.includes(lowerText(product)));
}
export function hasRequiredClaims(securityState = {}, requiredClaims = null) {
if (!requiredClaims || typeof requiredClaims !== 'object') {
return true;
}
const claims = getSessionClaims(securityState);
return Object.entries(requiredClaims).every(([key, expected]) => {
const actual = claims?.[key];
if (Array.isArray(expected)) {
const expectedSet = arrayOfLower(expected);
const actualSet = arrayOfLower(Array.isArray(actual) ? actual : [actual]);
return expectedSet.every((value) => actualSet.includes(value));
}
return actual === expected;
});
}
export function evaluateAuthRequirements(securityState = {}, requirements = {}) {
const requireUser = Boolean(requirements.require_user);
const requireAdmin = Boolean(requirements.require_admin);
const requiredRoles = arrayOfLower(requirements.required_roles);
const requiredProducts = requirements.required_products || [];
const requiredClaims = requirements.required_claims || null;
if (requireUser && !securityState?.isAuthenticated) {
return { allowed: false, requires_login: true, reason: 'Login required' };
}
if (requireAdmin && !isAdminUser(securityState)) {
return { allowed: false, requires_login: false, reason: 'Administrator access required' };
}
if (requiredRoles.length > 0) {
const grantedRoles = getUserRoles(securityState);
if (!requiredRoles.some((role) => grantedRoles.includes(role))) {
return { allowed: false, requires_login: false, reason: 'Required role missing' };
}
}
if (!hasRequiredProducts(securityState, requiredProducts)) {
return { allowed: false, requires_login: false, reason: 'Required product access missing' };
}
if (!hasRequiredClaims(securityState, requiredClaims)) {
return { allowed: false, requires_login: false, reason: 'Required token claims missing' };
}
return { allowed: true, requires_login: false, reason: 'Auth requirements satisfied' };
}
+27 -4
View File
@@ -1,10 +1,18 @@
import { normalizeRightsInput } from '../model/rights.js';
import { evaluateAuthRequirements } from './access-rules.js';
export async function evaluateRouteAccess(route = {}, securityService) {
const securityState = securityService.getState();
const options = route?.options || {};
const resourcePath = options.resource_path || route?.path || '/';
const requestedRights = normalizeRightsInput(options.required_rights || 0);
const authRequirements = {
require_user: options.require_user,
require_admin: options.require_admin,
required_roles: options.required_roles,
required_products: options.required_products,
required_claims: options.required_claims,
};
if (!securityState.enabled) {
return {
@@ -14,7 +22,14 @@ export async function evaluateRouteAccess(route = {}, securityService) {
};
}
if (!options.require_user && requestedRights === 0) {
const needsAuthCheck = Object.values(authRequirements).some((value) => {
if (Array.isArray(value)) {
return value.length > 0;
}
return Boolean(value);
});
if (!needsAuthCheck && requestedRights === 0) {
return {
allowed: true,
requires_login: false,
@@ -22,14 +37,22 @@ export async function evaluateRouteAccess(route = {}, securityService) {
};
}
if (options.require_user && !securityState.isAuthenticated) {
if (!securityState.initialized || securityState.loading) {
return {
allowed: false,
requires_login: true,
reason: 'Login required for route'
requires_login: false,
pending: true,
reason: 'Security state is still initializing'
};
}
if (needsAuthCheck) {
const authResult = evaluateAuthRequirements(securityState, authRequirements);
if (!authResult.allowed) {
return authResult;
}
}
if (requestedRights !== 0) {
return securityService.userPermitted(requestedRights, resourcePath, { redirectOnFail: false });
}
+170 -68
View File
@@ -1,7 +1,7 @@
import { useSyncExternalStore } from 'react';
import { setRouterPath } from '../../platform/compat.js';
import { getRouterPath, setRouterPath } from '../../platform/compat.js';
import { normalizeRightsInput } from '../model/rights.js';
import { BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
import { ApiSecurityPolicy, BasicSecurityPolicy, BstoreSecurityPolicy } from '../policy/index.js';
import { createSecurityRequestInterceptor, createSecurityResponseInterceptor } from './api-auth.js';
const DEFAULT_SECURITY_CONFIG = {
@@ -44,6 +44,9 @@ class SecurityService {
this.state = createInitialState();
this.listeners = new Set();
this.apiHooksInstalled = false;
this.initPromise = null;
this.registeredResourceKeys = new Set();
this.resourceRegistrationPromises = new Map();
}
subscribe(listener) {
@@ -73,6 +76,9 @@ class SecurityService {
}
_resolvePolicy(config) {
if (config.provider === 'api') {
return new ApiSecurityPolicy(config);
}
if (config.provider === 'bstore') {
return new BstoreSecurityPolicy(config);
}
@@ -81,70 +87,87 @@ class SecurityService {
async init(config = {}) {
const normalizedConfig = normalizeSecurityConfig(config);
if (!normalizedConfig.enabled) {
this.setState({
...createInitialState(),
initialized: true,
config: normalizedConfig,
enabled: false,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login
});
return this.state;
}
this.setState({
loading: true,
error: null,
enabled: true,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login,
config: normalizedConfig
});
try {
const policy = this._resolvePolicy(normalizedConfig);
await policy.init();
const session = await policy.getCurrentSession();
const user = session?.user_id ? await policy.getUser(session.user_id) : null;
const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null;
const profile = user ? await policy.getAccountProfile(user.id) : null;
const initTask = (async () => {
if (!normalizedConfig.enabled) {
this.setState({
...createInitialState(),
initialized: true,
config: normalizedConfig,
enabled: false,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login
});
return this.state;
}
this.setState({
initialized: true,
loading: false,
loading: true,
error: null,
enabled: true,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login,
config: normalizedConfig,
policy,
session,
user,
profile,
realm,
isAuthenticated: Boolean(session && user),
error: null
});
} catch (error) {
console.error('[Security] Failed to initialize security service:', error);
this.setState({
initialized: true,
loading: false,
enabled: normalizedConfig.enabled,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login,
config: normalizedConfig,
policy: null,
session: null,
user: null,
profile: null,
realm: null,
isAuthenticated: false,
error
});
try {
const policy = this._resolvePolicy(normalizedConfig);
await policy.init();
const session = await policy.getCurrentSession();
const user = session?.user_id ? await policy.getUser(session.user_id) : null;
const realm = user?.realm_id ? await policy.getRealm(user.realm_id) : null;
const profile = user ? await policy.getAccountProfile(user.id) : null;
this.setState({
initialized: true,
loading: false,
enabled: true,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login,
config: normalizedConfig,
policy,
session,
user,
profile,
realm,
isAuthenticated: Boolean(session && user),
error: null
});
} catch (error) {
console.error('[Security] Failed to initialize security service:', error);
this.setState({
initialized: true,
loading: false,
enabled: normalizedConfig.enabled,
provider: normalizedConfig.provider,
requireLogin: normalizedConfig.require_login,
config: normalizedConfig,
policy: null,
session: null,
user: null,
profile: null,
realm: null,
isAuthenticated: false,
error
});
}
return this.state;
})();
this.initPromise = initTask;
return initTask;
}
async waitUntilInitialized() {
if (this.state.initialized || !this.initPromise) {
return this.state;
}
try {
await this.initPromise;
} catch {
// State already captures the initialization failure.
}
return this.state;
}
@@ -161,12 +184,14 @@ class SecurityService {
if (!this.state.session?.jwt_token) {
return config;
}
const incomingHeaders = config.headers;
const headers = incomingHeaders instanceof Headers
? new Headers(incomingHeaders)
: new Headers(incomingHeaders || {});
headers.set('Authorization', `Bearer ${this.state.session.jwt_token}`);
return {
...config,
headers: {
...(config.headers || {}),
Authorization: `Bearer ${this.state.session.jwt_token}`
}
headers
};
}
@@ -176,7 +201,10 @@ class SecurityService {
async handleUnauthorizedResponse() {
if (this.state.isAuthenticated) {
await this.logout({ redirect: true });
const currentPath = await getRouterPath('/home');
const loginRoute = this.state.config.login_route || '/login';
const redirectTo = currentPath && currentPath !== loginRoute ? currentPath : null;
await this.logout({ redirect: true, redirectTo });
}
}
@@ -209,7 +237,7 @@ class SecurityService {
}
async logout(options = {}) {
const { redirect = true } = options;
const { redirect = true, redirectTo = null } = options;
if (this.state.policy && this.state.session) {
try {
@@ -233,7 +261,9 @@ class SecurityService {
});
if (redirect) {
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true);
await setRouterPath(this.state.config.logout_route || this.state.config.login_route || '/login', true, {
state: redirectTo ? { redirect_to: redirectTo } : null
});
}
}
@@ -259,10 +289,40 @@ class SecurityService {
}
async registerResource(resource) {
if (this.state.enabled && !this.state.initialized) {
await this.waitUntilInitialized();
}
if (!this.state.policy) {
return null;
}
return this.state.policy.registerResource(resource);
if (this.state.provider === 'api' && !this.state.isAuthenticated) {
return null;
}
const resourcePath = String(resource?.path || '').trim();
const resourceType = String(resource?.type || '').trim();
if (!resourcePath) {
return null;
}
const key = `${resourceType}:${resourcePath}`;
if (this.registeredResourceKeys.has(key)) {
return null;
}
if (this.resourceRegistrationPromises.has(key)) {
return this.resourceRegistrationPromises.get(key);
}
const task = (async () => {
try {
const registered = await this.state.policy.registerResource(resource);
this.registeredResourceKeys.add(key);
return registered;
} finally {
this.resourceRegistrationPromises.delete(key);
}
})();
this.resourceRegistrationPromises.set(key, task);
return task;
}
async userRequired(options = {}) {
@@ -275,7 +335,10 @@ class SecurityService {
}
if (options.redirect !== false) {
await setRouterPath(this.state.config.login_route || '/login', true);
const currentPath = await getRouterPath('/home');
await setRouterPath(this.state.config.login_route || '/login', true, {
state: currentPath ? { redirect_to: currentPath } : null
});
}
return { allowed: false, requires_login: true, reason: 'User login required' };
@@ -289,7 +352,10 @@ class SecurityService {
if (!this.state.isAuthenticated) {
const response = { allowed: false, requires_login: true, reason: 'User login required', matched_permits: [] };
if (options.redirectOnFail) {
await setRouterPath(this.state.config.login_route || '/login', true);
const currentPath = await getRouterPath('/home');
await setRouterPath(this.state.config.login_route || '/login', true, {
state: currentPath ? { redirect_to: currentPath } : null
});
}
return response;
}
@@ -302,7 +368,10 @@ class SecurityService {
);
if (!result.allowed && result.requires_login && options.redirectOnFail) {
await setRouterPath(this.state.config.login_route || '/login', true);
const currentPath = await getRouterPath('/home');
await setRouterPath(this.state.config.login_route || '/login', true, {
state: currentPath ? { redirect_to: currentPath } : null
});
}
return result;
@@ -351,11 +420,44 @@ class SecurityService {
await this.state.policy.changePassword(this.state.user.id, passwordInput);
}
async uploadAccountAvatar(file) {
if (!this.state.policy || !this.state.user || typeof this.state.policy.uploadAccountAvatar !== 'function') {
throw new Error('Avatar upload is not available');
}
const profile = await this.state.policy.uploadAccountAvatar(this.state.user.id, file);
const user = await this.state.policy.getUser(this.state.user.id);
this.setState({
profile,
user
});
return profile;
}
async listUsers() { return this.state.policy ? this.state.policy.listUsers() : []; }
async createUser(userData) { return this.state.policy ? this.state.policy.createUser(userData) : null; }
async updateUser(userId, patch) { return this.state.policy ? this.state.policy.updateUser(userId, patch) : null; }
async deleteUser(userId) { return this.state.policy ? this.state.policy.deleteUser(userId) : null; }
async listRoles() { return this.state.policy ? this.state.policy.listRoles() : []; }
async listSubjects() { return this.state.policy ? this.state.policy.listSubjects() : []; }
async createRole(roleData) { return this.state.policy ? this.state.policy.createRole(roleData) : null; }
async updateRole(roleId, patch) { return this.state.policy ? this.state.policy.updateRole(roleId, patch) : null; }
async deleteRole(roleId) { return this.state.policy ? this.state.policy.deleteRole(roleId) : null; }
async listRealms() { return this.state.policy ? this.state.policy.listRealms() : []; }
async createRealm(realmData) { return this.state.policy ? this.state.policy.createRealm(realmData) : null; }
async updateRealm(realmId, patch) { return this.state.policy ? this.state.policy.updateRealm(realmId, patch) : null; }
async deleteRealm(realmId) { return this.state.policy ? this.state.policy.deleteRealm(realmId) : null; }
async listResources() { return this.state.policy ? this.state.policy.listResources() : []; }
async createResource(resource) { return this.state.policy ? this.state.policy.registerResource(resource) : null; }
async updateResource(path, patch) { return this.state.policy ? this.state.policy.updateResource(path, patch) : null; }
async deleteResource(path) {
return this.state.policy && typeof this.state.policy.deleteResource === 'function'
? this.state.policy.deleteResource(path)
: null;
}
async listPermits() { return this.state.policy ? this.state.policy.listPermits() : []; }
async createPermit(permit) { return this.state.policy ? this.state.policy.grantPermit(permit) : null; }
async updatePermit(permitId, patch) { return this.state.policy ? this.state.policy.updatePermit(permitId, patch) : null; }
async deletePermit(permitId) { return this.state.policy ? this.state.policy.revokePermit(permitId) : null; }
}
export const securityService = new SecurityService();