feat(bucket-tenants): add persisted bucket-scoped tenant credentials with bucket export and import APIs

This commit is contained in:
2026-05-02 11:14:15 +00:00
parent 53d663597a
commit 7f2546e041
14 changed files with 1675 additions and 117 deletions
+295
View File
@@ -7,10 +7,14 @@ import * as paths from './paths.js';
export interface IStorageCredential {
accessKeyId: string;
secretAccessKey: string;
bucketName?: string;
region?: string;
}
export interface IStorageCredentialMetadata {
accessKeyId: string;
bucketName?: string;
region?: string;
}
/**
@@ -155,6 +159,88 @@ export interface IStorageStats {
storageLocations?: IStorageLocationSummary[];
}
export interface IBucketTenantInput {
bucketName: string;
accessKeyId?: string;
secretAccessKey?: string;
region?: string;
}
export interface IDeleteBucketTenantInput {
bucketName: string;
accessKeyId?: string;
}
export interface IBucketTenantMetadata {
bucketName: string;
accessKeyId: string;
region?: string;
}
export interface IBucketTenantDescriptor extends plugins.tsclass.storage.IS3Descriptor {
endpoint: string;
port: number;
region: string;
bucket: string;
bucketName: string;
accessKeyId: string;
secretAccessKey: string;
accessKey: string;
accessSecret: string;
useSsl: boolean;
ssl: boolean;
env: Record<string, string>;
}
export interface IBucketExportObject {
key: string;
size: number;
md5: string;
metadata: Record<string, string>;
dataHex: string;
}
export interface IBucketExport {
format: 'smartstorage.bucket.v1';
bucketName: string;
exportedAt: number;
objects: IBucketExportObject[];
}
export interface IExportBucketInput {
bucketName: string;
}
export interface IImportBucketInput {
bucketName: string;
source: IBucketExport;
}
export interface ISmartStorageHealth {
ok: boolean;
running: boolean;
storageDirectory: string;
auth: {
enabled: boolean;
credentialCount: number;
tenantCredentialCount: number;
};
bucketCount: number;
objectCount: number;
totalBytes: number;
cluster: IClusterHealth;
}
export interface ISmartStorageMetrics {
bucketCount: number;
objectCount: number;
totalBytes: number;
authCredentialCount: number;
tenantCredentialCount: number;
clusterEnabled: boolean;
prometheusText: string;
}
/**
* Known peer status from the local node's current cluster view.
*/
@@ -306,6 +392,14 @@ function mergeConfig(userConfig: ISmartStorageConfig): Required<ISmartStorageCon
} as Required<ISmartStorageConfig>;
}
function createAccessKeyId(): string {
return `SS${plugins.crypto.randomBytes(10).toString('hex').toUpperCase()}`;
}
function createSecretAccessKey(): string {
return plugins.crypto.randomBytes(32).toString('hex');
}
/**
* IPC command type map for RustBridge
*/
@@ -313,6 +407,35 @@ type TRustStorageCommands = {
start: { params: { config: Required<ISmartStorageConfig> }; result: {} };
stop: { params: {}; result: {} };
createBucket: { params: { name: string }; result: {} };
createBucketTenant: {
params: {
bucketName: string;
accessKeyId: string;
secretAccessKey: string;
region?: string;
};
result: IStorageCredential;
};
deleteBucketTenant: {
params: { bucketName: string; accessKeyId?: string };
result: {};
};
rotateBucketTenantCredentials: {
params: {
bucketName: string;
accessKeyId: string;
secretAccessKey: string;
region?: string;
};
result: IStorageCredential;
};
listBucketTenants: { params: {}; result: IBucketTenantMetadata[] };
getBucketTenantCredential: {
params: { bucketName: string };
result: IStorageCredential;
};
exportBucket: { params: { bucketName: string }; result: IBucketExport };
importBucket: { params: { bucketName: string; source: IBucketExport }; result: {} };
getStorageStats: { params: {}; result: IStorageStats };
listBucketSummaries: { params: {}; result: IBucketSummary[] };
listCredentials: { params: {}; result: IStorageCredentialMetadata[] };
@@ -334,6 +457,7 @@ export class SmartStorage {
// INSTANCE
public config: Required<ISmartStorageConfig>;
private bridge: InstanceType<typeof plugins.RustBridge<TRustStorageCommands>>;
private running = false;
constructor(configArg: ISmartStorageConfig = {}) {
this.config = mergeConfig(configArg);
@@ -353,6 +477,7 @@ export class SmartStorage {
throw new Error('Failed to spawn ruststorage binary. Make sure it is compiled (pnpm build).');
}
await this.bridge.sendCommand('start', { config: this.config });
this.running = true;
if (!this.config.server.silent) {
console.log('storage server is running');
@@ -382,11 +507,110 @@ export class SmartStorage {
};
}
private getEndpoint(): string {
return this.config.server.address === '0.0.0.0' ? 'localhost' : this.config.server.address!;
}
private buildBucketTenantDescriptor(
credential: IStorageCredential,
bucketNameArg: string,
): IBucketTenantDescriptor {
const bucketName = credential.bucketName || bucketNameArg;
const region = credential.region || this.config.server.region || 'us-east-1';
const endpoint = this.getEndpoint();
const port = this.config.server.port!;
const useSsl = false;
return {
endpoint,
port,
region,
bucket: bucketName,
bucketName,
accessKeyId: credential.accessKeyId,
secretAccessKey: credential.secretAccessKey,
accessKey: credential.accessKeyId,
accessSecret: credential.secretAccessKey,
useSsl,
ssl: useSsl,
env: {
S3_ENDPOINT: endpoint,
S3_PORT: String(port),
S3_REGION: region,
S3_BUCKET: bucketName,
S3_ACCESS_KEY_ID: credential.accessKeyId,
S3_SECRET_ACCESS_KEY: credential.secretAccessKey,
S3_USE_SSL: String(useSsl),
AWS_ACCESS_KEY_ID: credential.accessKeyId,
AWS_SECRET_ACCESS_KEY: credential.secretAccessKey,
AWS_REGION: region,
},
};
}
private assertTenantAuthEnabled(): void {
if (!this.config.auth.enabled) {
throw new Error('Bucket tenant APIs require auth.enabled=true.');
}
}
public async createBucket(bucketNameArg: string) {
await this.bridge.sendCommand('createBucket', { name: bucketNameArg });
return { name: bucketNameArg };
}
public async createBucketTenant(
tenantArg: IBucketTenantInput,
): Promise<IBucketTenantDescriptor> {
this.assertTenantAuthEnabled();
const credential = await this.bridge.sendCommand('createBucketTenant', {
bucketName: tenantArg.bucketName,
accessKeyId: tenantArg.accessKeyId || createAccessKeyId(),
secretAccessKey: tenantArg.secretAccessKey || createSecretAccessKey(),
region: tenantArg.region || this.config.server.region,
});
return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
}
public async deleteBucketTenant(tenantArg: IDeleteBucketTenantInput): Promise<void> {
this.assertTenantAuthEnabled();
await this.bridge.sendCommand('deleteBucketTenant', tenantArg);
}
public async rotateBucketTenantCredentials(
tenantArg: IBucketTenantInput,
): Promise<IBucketTenantDescriptor> {
this.assertTenantAuthEnabled();
const credential = await this.bridge.sendCommand('rotateBucketTenantCredentials', {
bucketName: tenantArg.bucketName,
accessKeyId: tenantArg.accessKeyId || createAccessKeyId(),
secretAccessKey: tenantArg.secretAccessKey || createSecretAccessKey(),
region: tenantArg.region || this.config.server.region,
});
return this.buildBucketTenantDescriptor(credential, tenantArg.bucketName);
}
public async listBucketTenants(): Promise<IBucketTenantMetadata[]> {
return this.bridge.sendCommand('listBucketTenants', {});
}
public async getBucketTenantDescriptor(optionsArg: {
bucketName: string;
}): Promise<IBucketTenantDescriptor> {
const credential = await this.bridge.sendCommand('getBucketTenantCredential', {
bucketName: optionsArg.bucketName,
});
return this.buildBucketTenantDescriptor(credential, optionsArg.bucketName);
}
public async exportBucket(optionsArg: IExportBucketInput): Promise<IBucketExport> {
return this.bridge.sendCommand('exportBucket', { bucketName: optionsArg.bucketName });
}
public async importBucket(optionsArg: IImportBucketInput): Promise<void> {
await this.bridge.sendCommand('importBucket', optionsArg);
}
public async getStorageStats(): Promise<IStorageStats> {
return this.bridge.sendCommand('getStorageStats', {});
}
@@ -408,8 +632,79 @@ export class SmartStorage {
return this.bridge.sendCommand('getClusterHealth', {});
}
public async getHealth(): Promise<ISmartStorageHealth> {
if (!this.running) {
return {
ok: false,
running: false,
storageDirectory: this.config.storage.directory || paths.bucketsDir,
auth: {
enabled: this.config.auth.enabled,
credentialCount: this.config.auth.credentials.length,
tenantCredentialCount: 0,
},
bucketCount: 0,
objectCount: 0,
totalBytes: 0,
cluster: { enabled: false },
};
}
const [stats, credentials, tenants, cluster] = await Promise.all([
this.getStorageStats(),
this.listCredentials(),
this.listBucketTenants(),
this.getClusterHealth(),
]);
return {
ok: true,
running: true,
storageDirectory: stats.storageDirectory,
auth: {
enabled: this.config.auth.enabled,
credentialCount: credentials.length,
tenantCredentialCount: tenants.length,
},
bucketCount: stats.bucketCount,
objectCount: stats.totalObjectCount,
totalBytes: stats.totalStorageBytes,
cluster,
};
}
public async getMetrics(): Promise<ISmartStorageMetrics> {
const health = await this.getHealth();
const clusterEnabled = health.cluster.enabled;
return {
bucketCount: health.bucketCount,
objectCount: health.objectCount,
totalBytes: health.totalBytes,
authCredentialCount: health.auth.credentialCount,
tenantCredentialCount: health.auth.tenantCredentialCount,
clusterEnabled,
prometheusText: [
'# HELP smartstorage_buckets_total Runtime bucket count.',
'# TYPE smartstorage_buckets_total gauge',
`smartstorage_buckets_total ${health.bucketCount}`,
'# HELP smartstorage_objects_total Runtime object count.',
'# TYPE smartstorage_objects_total gauge',
`smartstorage_objects_total ${health.objectCount}`,
'# HELP smartstorage_storage_bytes_total Runtime storage bytes.',
'# TYPE smartstorage_storage_bytes_total gauge',
`smartstorage_storage_bytes_total ${health.totalBytes}`,
'# HELP smartstorage_tenant_credentials_total Scoped bucket tenant credential count.',
'# TYPE smartstorage_tenant_credentials_total gauge',
`smartstorage_tenant_credentials_total ${health.auth.tenantCredentialCount}`,
'# HELP smartstorage_cluster_enabled Cluster mode enabled.',
'# TYPE smartstorage_cluster_enabled gauge',
`smartstorage_cluster_enabled ${clusterEnabled ? 1 : 0}`,
].join('\n'),
};
}
public async stop() {
await this.bridge.sendCommand('stop', {});
this.bridge.kill();
this.running = false;
}
}