mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
Chat file storage already ran on our S3 (s3-api.hanzo.ai, bucket chat-files) but every object landed at a flat `<basePath>/<userId>/<file>` key: users were separated, orgs were not. There was no way to scope, administer or delete an org's files as a unit, and nothing enforced an org boundary in the shared bucket. The tenant IS the IAM org — nothing new is invented or persisted: - `resolveRequestOrg` (beside `resolveActiveOrg`, in the module that already owns tenant resolution) = the member's selected working org (`hanzo_active_org`, which the server admits only from their own membership set) else their home org, the projection of the verified IAM `owner` claim. No new field, no parallel store, no second resolver. - Writes key objects `t/<org>/<basePath>/<userId>/<file>`, byte-identical to the convention already in packages/api/src/storage/s3, so both impls address the same object for the same request. Backward compatible by construction: a caller IAM gives no org keeps the historical flat key; `refreshS3Url` derives keys from the stored URL; and `getS3URL` falls back to the flat key when nothing is at the org key, so pre-existing files keep resolving through the migration window. `migrateToOrgKeys.js` backfills the bucket to one shape — dry-run by default, copy-verify-delete, re-runnable, and it too reads the org from IAM (a user with no IAM org is skipped, never invented). Tests: 8 new (4 key-convention incl. traversal refusal + org separation, 4 resolution order). The 4 failures in packages/api/src/endpoints/custom are pre-existing on main (Agents API key resolution + SSRF guard), unchanged by this. NOTE: chat has two S3 implementations — this live JS one and the richer TS one in packages/api/src/storage/s3 that the file path does not use. They now share the key convention; collapsing them to one is a separate refactor.
193 lines
5.9 KiB
JavaScript
193 lines
5.9 KiB
JavaScript
/**
|
|
* Move pre-tenancy S3 objects under their owning org's prefix.
|
|
*
|
|
* New uploads are written to `t/<org>/<basePath>/<userId>/<file>` (see getS3Key).
|
|
* Objects written before that keep the flat `<basePath>/<userId>/<file>` key, so
|
|
* this backfills the bucket to one shape.
|
|
*
|
|
* The org is read from IAM, never from a stored tenant field: each file's owning
|
|
* user carries `organization`, the projection of the verified IAM `owner` claim.
|
|
* A user with no IAM org is SKIPPED — there is nothing to scope them to, and
|
|
* inventing a value here would be inventing tenancy.
|
|
*
|
|
* Safety:
|
|
* - DRY RUN by default; pass `--apply` to actually copy.
|
|
* - Copy-then-verify-then-delete. The source is removed only after the
|
|
* destination is confirmed present, so an interrupted run can be re-run.
|
|
* - Re-runnable: an object already at its org key is left alone.
|
|
* - Reads keep working throughout — getS3URL falls back to the flat key while a
|
|
* file is still un-migrated, and refreshS3Url derives keys from the stored URL.
|
|
*
|
|
* Usage:
|
|
* node api/server/services/Files/S3/migrateToOrgKeys.js # dry run
|
|
* node api/server/services/Files/S3/migrateToOrgKeys.js --apply # perform
|
|
* node api/server/services/Files/S3/migrateToOrgKeys.js --apply --limit 100
|
|
*/
|
|
const path = require('path');
|
|
const mongoose = require('mongoose');
|
|
const {
|
|
CopyObjectCommand,
|
|
DeleteObjectCommand,
|
|
HeadObjectCommand,
|
|
ListObjectsV2Command,
|
|
} = require('@aws-sdk/client-s3');
|
|
const { initializeS3 } = require('@hanzochat/api');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
|
|
const bucketName = process.env.AWS_BUCKET_NAME;
|
|
const TENANT_PREFIX = 't/';
|
|
|
|
const argHas = (flag) => process.argv.includes(flag);
|
|
const argValue = (flag, fallback) => {
|
|
const i = process.argv.indexOf(flag);
|
|
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
|
};
|
|
|
|
/**
|
|
* Map userId -> IAM org for every user that has one. One pass, so the migration
|
|
* does not issue a query per object.
|
|
*/
|
|
async function loadUserOrgs() {
|
|
const users = await mongoose.connection
|
|
.collection('users')
|
|
.find({ organization: { $exists: true, $ne: '' } }, { projection: { organization: 1 } })
|
|
.toArray();
|
|
const orgs = new Map();
|
|
for (const u of users) {
|
|
orgs.set(u._id.toString(), u.organization);
|
|
}
|
|
return orgs;
|
|
}
|
|
|
|
/** Every object currently at a flat (un-scoped) key. */
|
|
async function* listFlatObjects(s3) {
|
|
let ContinuationToken;
|
|
do {
|
|
const page = await s3.send(
|
|
new ListObjectsV2Command({ Bucket: bucketName, ContinuationToken }),
|
|
);
|
|
for (const obj of page.Contents ?? []) {
|
|
if (!obj.Key.startsWith(TENANT_PREFIX)) {
|
|
yield obj.Key;
|
|
}
|
|
}
|
|
ContinuationToken = page.IsTruncated ? page.NextContinuationToken : undefined;
|
|
} while (ContinuationToken);
|
|
}
|
|
|
|
/** `<basePath>/<userId>/<fileName...>` — the historical flat key shape. */
|
|
function parseFlatKey(key) {
|
|
const parts = key.split('/');
|
|
if (parts.length < 3) {
|
|
return null;
|
|
}
|
|
const [basePath, userId, ...rest] = parts;
|
|
return { basePath, userId, fileName: rest.join('/') };
|
|
}
|
|
|
|
async function objectExists(s3, Key) {
|
|
try {
|
|
await s3.send(new HeadObjectCommand({ Bucket: bucketName, Key }));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const apply = argHas('--apply');
|
|
const limit = parseInt(argValue('--limit', '0'), 10) || Infinity;
|
|
|
|
if (!bucketName) {
|
|
throw new Error('AWS_BUCKET_NAME is not set');
|
|
}
|
|
if (!process.env.MONGO_URI) {
|
|
throw new Error('MONGO_URI is not set');
|
|
}
|
|
|
|
await mongoose.connect(process.env.MONGO_URI);
|
|
const s3 = initializeS3();
|
|
if (!s3) {
|
|
throw new Error('S3 is not initialized (check S3/AWS credentials)');
|
|
}
|
|
|
|
const orgs = await loadUserOrgs();
|
|
logger.info(
|
|
`[migrateToOrgKeys] ${apply ? 'APPLY' : 'DRY RUN'} — ${orgs.size} users carry an IAM org`,
|
|
);
|
|
|
|
const stats = { scanned: 0, moved: 0, skippedNoOrg: 0, skippedShape: 0, failed: 0 };
|
|
|
|
for await (const key of listFlatObjects(s3)) {
|
|
if (stats.scanned >= limit) {
|
|
break;
|
|
}
|
|
stats.scanned++;
|
|
|
|
const parsed = parseFlatKey(key);
|
|
if (!parsed) {
|
|
stats.skippedShape++;
|
|
continue;
|
|
}
|
|
|
|
const org = orgs.get(parsed.userId);
|
|
if (!org) {
|
|
// No IAM org for this owner — nothing to scope to. Left exactly as-is.
|
|
stats.skippedNoOrg++;
|
|
continue;
|
|
}
|
|
|
|
const destKey = `${TENANT_PREFIX}${org}/${key}`;
|
|
if (!apply) {
|
|
logger.info(`[migrateToOrgKeys] would move: ${key} -> ${destKey}`);
|
|
stats.moved++;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (await objectExists(s3, destKey)) {
|
|
// A previous run already placed it; drop the leftover source.
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
|
|
stats.moved++;
|
|
continue;
|
|
}
|
|
await s3.send(
|
|
new CopyObjectCommand({
|
|
Bucket: bucketName,
|
|
CopySource: `${bucketName}/${key}`,
|
|
Key: destKey,
|
|
}),
|
|
);
|
|
// Verify before removing the only remaining copy.
|
|
if (!(await objectExists(s3, destKey))) {
|
|
throw new Error('destination missing after copy');
|
|
}
|
|
await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
|
|
stats.moved++;
|
|
} catch (err) {
|
|
stats.failed++;
|
|
logger.error(`[migrateToOrgKeys] FAILED ${key} -> ${destKey}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
logger.info(
|
|
`[migrateToOrgKeys] done — scanned=${stats.scanned} moved=${stats.moved} ` +
|
|
`skipped(no IAM org)=${stats.skippedNoOrg} skipped(shape)=${stats.skippedShape} failed=${stats.failed}`,
|
|
);
|
|
if (!apply) {
|
|
logger.info('[migrateToOrgKeys] DRY RUN — re-run with --apply to perform the move');
|
|
}
|
|
|
|
await mongoose.disconnect();
|
|
process.exit(stats.failed > 0 ? 1 : 0);
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main().catch((err) => {
|
|
logger.error(`[migrateToOrgKeys] ${err.stack || err.message}`);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
module.exports = { parseFlatKey };
|