Files
bface/src/security/policy/BasicSecurityPolicy.js
Amer Agovic 94a9f32969 Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui.
- Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*.
- Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell.
- Stabilize tests with Node localStorage file; env tests pass.
- Update README and component docs for services, menus, API client, and development.
2026-04-18 10:43:52 -05:00

534 lines
16 KiB
JavaScript

import { getProvider } from '../../platform/storage.js';
import { SecurityPolicy } from './SecurityPolicy.js';
import { AccountProfile, Permit, Realm, Resource, Role, Session, User, SECURITY_RIGHTS, hasRequiredRights, normalizeRightsInput } from '../model/index.js';
const STORAGE_KEY = 'security.basic.dataset';
const SESSION_KEY = 'security.basic.session';
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function nowISO() {
return new Date().toISOString();
}
function createId(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function pathMatches(resourcePath, targetPath) {
if (!resourcePath || resourcePath === '*') {
return true;
}
if (resourcePath.endsWith('*')) {
const prefix = resourcePath.slice(0, -1);
return targetPath.startsWith(prefix);
}
return targetPath === resourcePath || targetPath.startsWith(`${resourcePath}/`);
}
async function hashPassword(password) {
if (typeof crypto === 'undefined' || !crypto.subtle) {
return `plain:${password}`;
}
const encoded = new TextEncoder().encode(password);
const digest = await crypto.subtle.digest('SHA-256', encoded);
const hashArray = Array.from(new Uint8Array(digest));
const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
return `sha256:${hashHex}`;
}
async function verifyPassword(password, storedHash) {
if (!storedHash) {
return false;
}
if (storedHash.startsWith('plain:')) {
return storedHash === `plain:${password}`;
}
return (await hashPassword(password)) === storedHash;
}
function evaluateAgainstDataset(dataset, userId, rights, resourcePath, context = {}) {
const targetRights = normalizeRightsInput(rights);
const targetPath = resourcePath || context.resource_path || '/';
if (!userId) {
return {
allowed: false,
requires_login: true,
reason: 'Authentication required',
matched_permits: []
};
}
const user = dataset.users.find((item) => item.id === userId);
if (!user) {
return {
allowed: false,
requires_login: true,
reason: 'Session user not found',
matched_permits: []
};
}
const principals = [
{ principal_type: 'user', principal_id: user.id }
];
user.role_ids.forEach((roleId) => principals.push({ principal_type: 'role', principal_id: roleId }));
const matchingPermits = dataset.permits.filter((permit) => {
const principalMatch = principals.some((principal) => {
return 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, targetRights));
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, targetRights));
if (allowMatches.length > 0 || targetRights === 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)
};
}
export class BasicSecurityPolicy extends SecurityPolicy {
constructor(config = {}) {
super(config);
this.storage = getProvider('kv', 'security.basic');
this.dataset = null;
}
async init() {
const dataset = await this.storage.get(STORAGE_KEY, null);
if (dataset) {
this.dataset = dataset;
return;
}
const adminRole = new Role({
id: 'role_admin',
name: 'Administrators',
description: 'Full access to all registered resources'
});
const memberRole = new Role({
id: 'role_member',
name: 'Members',
description: 'Standard authenticated user role'
});
const realm = new Realm({
id: 'local',
name: 'Local Realm',
description: 'Local skeleton application realm'
});
const adminUser = new User({
id: 'user_admin',
username: 'admin',
display_name: 'Local Administrator',
email: 'admin@local.realm',
realm_id: realm.id,
role_ids: [adminRole.id],
password_hash: await hashPassword('admin')
});
const demoUser = new User({
id: 'user_demo',
username: 'demo',
display_name: 'Demo User',
email: 'demo@local.realm',
realm_id: realm.id,
role_ids: [memberRole.id],
password_hash: await hashPassword('demo')
});
const adminProfile = new AccountProfile({
user_id: adminUser.id,
display_name: adminUser.display_name,
email: adminUser.email
});
const demoProfile = new AccountProfile({
user_id: demoUser.id,
display_name: demoUser.display_name,
email: demoUser.email
});
this.dataset = {
version: 1,
realms: [realm],
roles: [adminRole, memberRole],
users: [adminUser, demoUser],
resources: [
new Resource({ path: '/login', realm_id: realm.id, type: 'ui-route' }),
new Resource({ path: '/settings/account', realm_id: realm.id, type: 'ui-route' }),
new Resource({ path: '/settings/security', realm_id: realm.id, type: 'ui-route' })
],
permits: [
new Permit({
id: 'permit_admin_all',
principal_type: 'role',
principal_id: adminRole.id,
resource_path: '*',
rights: Object.values(SECURITY_RIGHTS).reduce((mask, value) => mask | value, 0),
effect: 'allow'
}),
new Permit({
id: 'permit_member_account',
principal_type: 'role',
principal_id: memberRole.id,
resource_path: '/settings/account',
rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.write,
effect: 'allow'
}),
new Permit({
id: 'permit_member_home',
principal_type: 'role',
principal_id: memberRole.id,
resource_path: '/home',
rights: SECURITY_RIGHTS.read | SECURITY_RIGHTS.execute,
effect: 'allow'
})
],
profiles: [adminProfile, demoProfile]
};
await this._persistDataset();
}
async _persistDataset() {
await this.storage.set(STORAGE_KEY, clone(this.dataset));
}
async _loadDataset() {
if (!this.dataset) {
await this.init();
}
return this.dataset;
}
async authenticate(credentials = {}) {
const dataset = await this._loadDataset();
const identifier = (credentials.username || credentials.email || '').trim().toLowerCase();
const password = credentials.password || '';
const user = dataset.users.find((candidate) => {
return candidate.username.toLowerCase() === identifier || candidate.email.toLowerCase() === identifier;
});
if (!user) {
throw new Error('Unknown user');
}
const valid = await verifyPassword(password, user.password_hash);
if (!valid) {
throw new Error('Invalid password');
}
const session = new Session({
user_id: user.id,
realm_id: user.realm_id,
jwt_token: `local.${user.id}.${Date.now()}`,
issued_on: nowISO(),
auth_provider: 'basic',
status: 'active'
});
await this.saveSession(session);
return {
session,
user: clone(user),
profile: await this.getAccountProfile(user.id)
};
}
async logout() {
await this.clearSession();
}
async getCurrentSession() {
const session = await this.storage.get(SESSION_KEY, null);
return session ? new Session(session) : null;
}
async saveSession(session) {
await this.storage.set(SESSION_KEY, clone(session));
}
async clearSession() {
await this.storage.remove(SESSION_KEY);
}
async listUsers() {
const dataset = await this._loadDataset();
return clone(dataset.users);
}
async getUser(userId) {
const dataset = await this._loadDataset();
const user = dataset.users.find((item) => item.id === userId);
return user ? clone(user) : null;
}
async createUser(userData) {
const dataset = await this._loadDataset();
const createdOn = nowISO();
const user = new User({
...userData,
id: userData.id || createId('user'),
created_on: createdOn,
updated_on: createdOn,
password_hash: await hashPassword(userData.password || 'changeme')
});
dataset.users.push(user);
dataset.profiles.push(new AccountProfile({
user_id: user.id,
display_name: user.display_name,
email: user.email,
image_url: user.image_url || ''
}));
await this._persistDataset();
return clone(user);
}
async updateUser(userId, patch) {
const dataset = await this._loadDataset();
const user = dataset.users.find((item) => item.id === userId);
if (!user) {
throw new Error(`User not found: ${userId}`);
}
Object.assign(user, patch, { updated_on: nowISO() });
await this._persistDataset();
return clone(user);
}
async deleteUser(userId) {
const dataset = await this._loadDataset();
dataset.users = dataset.users.filter((item) => item.id !== userId);
dataset.profiles = dataset.profiles.filter((item) => item.user_id !== userId);
await this._persistDataset();
}
async listRoles(realmId = null) {
const dataset = await this._loadDataset();
return clone(realmId ? dataset.roles.filter((role) => role.realm_id === realmId) : dataset.roles);
}
async getRole(roleId) {
const dataset = await this._loadDataset();
const role = dataset.roles.find((item) => item.id === roleId);
return role ? clone(role) : null;
}
async createRole(roleData) {
const dataset = await this._loadDataset();
const role = new Role({
...roleData,
id: roleData.id || createId('role'),
created_on: nowISO(),
updated_on: nowISO()
});
dataset.roles.push(role);
await this._persistDataset();
return clone(role);
}
async updateRole(roleId, patch) {
const dataset = await this._loadDataset();
const role = dataset.roles.find((item) => item.id === roleId);
if (!role) {
throw new Error(`Role not found: ${roleId}`);
}
Object.assign(role, patch, { updated_on: nowISO() });
await this._persistDataset();
return clone(role);
}
async deleteRole(roleId) {
const dataset = await this._loadDataset();
dataset.roles = dataset.roles.filter((item) => item.id !== roleId);
dataset.users.forEach((user) => {
user.role_ids = user.role_ids.filter((item) => item !== roleId);
});
dataset.permits = dataset.permits.filter((permit) => !(permit.principal_type === 'role' && permit.principal_id === roleId));
await this._persistDataset();
}
async listRealms() {
const dataset = await this._loadDataset();
return clone(dataset.realms);
}
async getRealm(realmId) {
const dataset = await this._loadDataset();
const realm = dataset.realms.find((item) => item.id === realmId);
return realm ? clone(realm) : null;
}
async createRealm(realmData) {
const dataset = await this._loadDataset();
const realm = new Realm({
...realmData,
id: realmData.id || createId('realm'),
created_on: nowISO(),
updated_on: nowISO()
});
dataset.realms.push(realm);
await this._persistDataset();
return clone(realm);
}
async updateRealm(realmId, patch) {
const dataset = await this._loadDataset();
const realm = dataset.realms.find((item) => item.id === realmId);
if (!realm) {
throw new Error(`Realm not found: ${realmId}`);
}
Object.assign(realm, patch, { updated_on: nowISO() });
await this._persistDataset();
return clone(realm);
}
async registerResource(resourceData) {
const dataset = await this._loadDataset();
const existing = dataset.resources.find((item) => item.path === resourceData.path);
if (existing) {
Object.assign(existing, resourceData, { updated_on: nowISO() });
await this._persistDataset();
return clone(existing);
}
const resource = new Resource({
...resourceData,
created_on: nowISO(),
updated_on: nowISO()
});
dataset.resources.push(resource);
await this._persistDataset();
return clone(resource);
}
async listResources(realmId = null) {
const dataset = await this._loadDataset();
return clone(realmId ? dataset.resources.filter((item) => item.realm_id === realmId) : dataset.resources);
}
async listPermits(filters = {}) {
const dataset = await this._loadDataset();
let items = dataset.permits;
if (filters.principal_type) {
items = items.filter((permit) => permit.principal_type === filters.principal_type);
}
if (filters.principal_id) {
items = items.filter((permit) => permit.principal_id === filters.principal_id);
}
return clone(items);
}
async grantPermit(permitData) {
const dataset = await this._loadDataset();
const permit = new Permit({
...permitData,
id: permitData.id || createId('permit'),
created_on: nowISO(),
updated_on: nowISO()
});
dataset.permits.push(permit);
await this._persistDataset();
return clone(permit);
}
async revokePermit(permitId) {
const dataset = await this._loadDataset();
dataset.permits = dataset.permits.filter((item) => item.id !== permitId);
await this._persistDataset();
}
async getAccountProfile(userId) {
const dataset = await this._loadDataset();
const profile = dataset.profiles.find((item) => item.user_id === userId);
return profile ? clone(profile) : null;
}
async updateAccountProfile(userId, patch) {
const dataset = await this._loadDataset();
const profile = dataset.profiles.find((item) => item.user_id === userId);
const user = dataset.users.find((item) => item.id === userId);
if (!profile || !user) {
throw new Error(`Profile not found for user: ${userId}`);
}
Object.assign(profile, patch, { updated_on: nowISO() });
if (patch.display_name !== undefined) user.display_name = patch.display_name;
if (patch.email !== undefined) user.email = patch.email;
if (patch.image_url !== undefined) user.image_url = patch.image_url;
user.updated_on = nowISO();
await this._persistDataset();
return clone(profile);
}
async changePassword(userId, passwordInput = {}) {
const dataset = await this._loadDataset();
const user = dataset.users.find((item) => item.id === userId);
if (!user) {
throw new Error(`User not found: ${userId}`);
}
if (passwordInput.currentPassword) {
const valid = await verifyPassword(passwordInput.currentPassword, user.password_hash);
if (!valid) {
throw new Error('Current password is invalid');
}
}
if (!passwordInput.newPassword) {
throw new Error('New password is required');
}
user.password_hash = await hashPassword(passwordInput.newPassword);
user.updated_on = nowISO();
await this._persistDataset();
}
async evaluate(userId, rights, resourcePath, context = {}) {
const dataset = await this._loadDataset();
return evaluateAgainstDataset(dataset, userId, rights, resourcePath, context);
}
evaluateSync(userId, rights, resourcePath, context = {}) {
if (!this.dataset) {
return {
allowed: false,
requires_login: false,
reason: 'Security dataset is not initialized',
matched_permits: []
};
}
return evaluateAgainstDataset(this.dataset, userId, rights, resourcePath, context);
}
}