import type { ObjectStorageContainer } from './objectstoragecontainer.ts'; import type * as interfaces from '../../ts_interfaces/index.ts'; export class PolicyManager { private objectStorageRef: ObjectStorageContainer; private data: interfaces.data.IPoliciesData = { namedPolicies: {}, bucketPolicyAttachments: {}, }; private filePath: string; constructor(objectStorageRef: ObjectStorageContainer) { this.objectStorageRef = objectStorageRef; const storageDir = objectStorageRef.config.storageDirectory; this.filePath = `${storageDir}/.objectstorage/policies.json`; } // ── Persistence ── public async load(): Promise { try { const dirPath = this.filePath.substring(0, this.filePath.lastIndexOf('/')); await Deno.mkdir(dirPath, { recursive: true }); const content = await Deno.readTextFile(this.filePath); this.data = JSON.parse(content); } catch { // File doesn't exist yet — start with empty data this.data = { namedPolicies: {}, bucketPolicyAttachments: {} }; } } private async save(): Promise { const dirPath = this.filePath.substring(0, this.filePath.lastIndexOf('/')); await Deno.mkdir(dirPath, { recursive: true }); await Deno.writeTextFile(this.filePath, JSON.stringify(this.data, null, 2)); } // ── CRUD ── public listPolicies(): interfaces.data.INamedPolicy[] { return Object.values(this.data.namedPolicies); } public getPolicy(policyId: string): interfaces.data.INamedPolicy | null { return this.data.namedPolicies[policyId] || null; } public async createPolicy( name: string, description: string, statements: interfaces.data.IObjstStatement[], ): Promise { const id = crypto.randomUUID(); const now = Date.now(); const policy: interfaces.data.INamedPolicy = { id, name, description, statements, createdAt: now, updatedAt: now, }; this.data.namedPolicies[id] = policy; await this.save(); return policy; } public async updatePolicy( policyId: string, name: string, description: string, statements: interfaces.data.IObjstStatement[], ): Promise { const existing = this.data.namedPolicies[policyId]; if (!existing) { throw new Error(`Policy not found: ${policyId}`); } existing.name = name; existing.description = description; existing.statements = statements; existing.updatedAt = Date.now(); await this.save(); // Recompute policies for all buckets that have this policy attached const affectedBuckets = this.getBucketsForPolicy(policyId); for (const bucket of affectedBuckets) { await this.recomputeAndApplyPolicy(bucket); } return existing; } public async deletePolicy(policyId: string): Promise { if (!this.data.namedPolicies[policyId]) { throw new Error(`Policy not found: ${policyId}`); } // Find all affected buckets before deleting const affectedBuckets = this.getBucketsForPolicy(policyId); // Remove from namedPolicies delete this.data.namedPolicies[policyId]; // Remove from all bucket attachments for (const bucket of Object.keys(this.data.bucketPolicyAttachments)) { this.data.bucketPolicyAttachments[bucket] = this.data.bucketPolicyAttachments[bucket].filter( (id) => id !== policyId, ); if (this.data.bucketPolicyAttachments[bucket].length === 0) { delete this.data.bucketPolicyAttachments[bucket]; } } await this.save(); // Recompute policies for affected buckets for (const bucket of affectedBuckets) { await this.recomputeAndApplyPolicy(bucket); } } // ── Attachments ── public getBucketAttachments(bucketName: string): { attachedPolicies: interfaces.data.INamedPolicy[]; availablePolicies: interfaces.data.INamedPolicy[]; } { const attachedIds = this.data.bucketPolicyAttachments[bucketName] || []; const allPolicies = this.listPolicies(); const attachedPolicies = allPolicies.filter((p) => attachedIds.includes(p.id)); const availablePolicies = allPolicies.filter((p) => !attachedIds.includes(p.id)); return { attachedPolicies, availablePolicies }; } public getBucketsForPolicy(policyId: string): string[] { const buckets: string[] = []; for (const [bucket, policyIds] of Object.entries(this.data.bucketPolicyAttachments)) { if (policyIds.includes(policyId)) { buckets.push(bucket); } } return buckets; } public async attachPolicyToBucket(policyId: string, bucketName: string): Promise { if (!this.data.namedPolicies[policyId]) { throw new Error(`Policy not found: ${policyId}`); } if (!this.data.bucketPolicyAttachments[bucketName]) { this.data.bucketPolicyAttachments[bucketName] = []; } if (!this.data.bucketPolicyAttachments[bucketName].includes(policyId)) { this.data.bucketPolicyAttachments[bucketName].push(policyId); } await this.save(); await this.recomputeAndApplyPolicy(bucketName); } public async detachPolicyFromBucket(policyId: string, bucketName: string): Promise { if (this.data.bucketPolicyAttachments[bucketName]) { this.data.bucketPolicyAttachments[bucketName] = this.data.bucketPolicyAttachments[bucketName].filter( (id) => id !== policyId, ); if (this.data.bucketPolicyAttachments[bucketName].length === 0) { delete this.data.bucketPolicyAttachments[bucketName]; } } await this.save(); await this.recomputeAndApplyPolicy(bucketName); } public async setPolicyBuckets(policyId: string, bucketNames: string[]): Promise { if (!this.data.namedPolicies[policyId]) { throw new Error(`Policy not found: ${policyId}`); } const oldBuckets = this.getBucketsForPolicy(policyId); const newBucketsSet = new Set(bucketNames); const oldBucketsSet = new Set(oldBuckets); // Remove from buckets no longer in the list for (const bucket of oldBuckets) { if (!newBucketsSet.has(bucket)) { this.data.bucketPolicyAttachments[bucket] = (this.data.bucketPolicyAttachments[bucket] || []).filter( (id) => id !== policyId, ); if (this.data.bucketPolicyAttachments[bucket]?.length === 0) { delete this.data.bucketPolicyAttachments[bucket]; } } } // Add to new buckets for (const bucket of bucketNames) { if (!oldBucketsSet.has(bucket)) { if (!this.data.bucketPolicyAttachments[bucket]) { this.data.bucketPolicyAttachments[bucket] = []; } if (!this.data.bucketPolicyAttachments[bucket].includes(policyId)) { this.data.bucketPolicyAttachments[bucket].push(policyId); } } } await this.save(); // Recompute all affected buckets (union of old and new) const allAffected = new Set([...oldBuckets, ...bucketNames]); for (const bucket of allAffected) { await this.recomputeAndApplyPolicy(bucket); } } // ── Cleanup ── public async onBucketDeleted(bucketName: string): Promise { if (this.data.bucketPolicyAttachments[bucketName]) { delete this.data.bucketPolicyAttachments[bucketName]; await this.save(); } } // ── Merge & Apply ── private async recomputeAndApplyPolicy(bucketName: string): Promise { const attachedIds = this.data.bucketPolicyAttachments[bucketName] || []; if (attachedIds.length === 0) { // No policies attached — remove any existing policy try { await this.objectStorageRef.deleteBucketPolicy(bucketName); } catch { // NoSuchBucketPolicy is fine } return; } // Gather all statements from attached policies const allStatements: interfaces.data.IObjstStatement[] = []; for (const policyId of attachedIds) { const policy = this.data.namedPolicies[policyId]; if (!policy) continue; for (const stmt of policy.statements) { // Deep clone and replace ${bucket} placeholder const cloned = JSON.parse(JSON.stringify(stmt)) as interfaces.data.IObjstStatement; cloned.Resource = this.replaceBucketPlaceholder(cloned.Resource, bucketName); allStatements.push(cloned); } } const mergedPolicy = { Version: '2012-10-17', Statement: allStatements, }; await this.objectStorageRef.putBucketPolicy(bucketName, JSON.stringify(mergedPolicy)); } private replaceBucketPlaceholder( resource: string | string[], bucketName: string, ): string | string[] { if (typeof resource === 'string') { return resource.replace(/\$\{bucket\}/g, bucketName); } return resource.map((r) => r.replace(/\$\{bucket\}/g, bucketName)); } }