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); } }