- Rename from @lossless.zone/s3container to @lossless.zone/objectstorage - Replace @push.rocks/smarts3 with @push.rocks/smartstorage - Change env var prefix from S3_ to OBJST_ - Rename S3Container class to ObjectStorageContainer - Update web component prefix from s3c- to objst- - Update UI labels, CLI flags, documentation, and Docker config
270 lines
8.7 KiB
TypeScript
270 lines
8.7 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<interfaces.data.INamedPolicy> {
|
|
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<interfaces.data.INamedPolicy> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
if (this.data.bucketPolicyAttachments[bucketName]) {
|
|
delete this.data.bucketPolicyAttachments[bucketName];
|
|
await this.save();
|
|
}
|
|
}
|
|
|
|
// ── Merge & Apply ──
|
|
|
|
private async recomputeAndApplyPolicy(bucketName: string): Promise<void> {
|
|
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));
|
|
}
|
|
}
|