- 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.
534 lines
16 KiB
JavaScript
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);
|
|
}
|
|
}
|