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.
This commit is contained in:
@@ -0,0 +1,533 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user