// Access rules for one hosted user: what s3d cannot express on its own. // // public anyone may read a drive or a folder (rights: read | read-list) // link anyone holding the link may read one file until it expires // grant whoever holds a gateway-minted S3 key may act on a drive or a // folder with the rights given: read, add (new files only) or edit // // Prefixes are plain paths. Files under a rule's prefix are stored readable // (the owner's Pithos converts them), because the people a rule is for have // no decryption key. import crypto from 'node:crypto'; export const RIGHTS = { public: ['read', 'read-list'], link: ['read'], grant: ['read', 'add', 'edit'], }; const id = (n = 12) => crypto.randomBytes(n).toString('base64url'); export function validateRule(input, buckets) { const type = input.type; if (!RIGHTS[type]) throw new Error('rule type must be public, link or grant'); const rights = input.rights || RIGHTS[type][0]; if (!RIGHTS[type].includes(rights)) throw new Error(`a ${type} rule allows ${RIGHTS[type].join(', ')}`); const bucket = String(input.bucket || ''); if (buckets && !buckets.includes(bucket)) throw new Error(`no drive named "${bucket}"`); const prefix = String(input.prefix || ''); if (prefix.startsWith('/') || prefix.includes('//')) throw new Error('bad folder path'); if (type === 'link' && (!prefix || prefix.endsWith('/'))) throw new Error('a link is for one file'); if (type !== 'link' && prefix && !prefix.endsWith('/')) throw new Error('rules apply to a drive or a folder (end it with /)'); let expires = null; if (input.expires != null) { expires = Number(input.expires); if (!Number.isFinite(expires) || expires < Date.now()) throw new Error('the expiry must be in the future'); } if (type === 'link' && !expires) throw new Error('links need an expiry'); const rule = { id: id(), type, bucket, prefix, rights, expires, label: String(input.label || '').slice(0, 120), createdAt: Date.now() }; if (type === 'link') rule.token = id(24); if (type === 'grant') { rule.accessKeyId = 'PG' + crypto.randomBytes(9).toString('hex').toUpperCase(); rule.secretKey = id(30); } return rule; } export const live = (r, now = Date.now()) => !r.expires || r.expires > now; // What an S3 request wants to do, from method, query and key. export function classify(method, query, key, headers = {}) { if (key == null || key === '') { if (method === 'GET' && !hasAny(query, ['policy', 'acl', 'cors', 'lifecycle', 'versioning', 'tagging', 'uploads'])) return { op: 'list' }; return { op: 'bucket-admin' }; } if (headers['x-amz-copy-source']) return { op: 'copy' }; if (hasAny(query, ['acl', 'tagging', 'retention', 'legal-hold'])) return { op: 'object-admin' }; if (method === 'GET' || method === 'HEAD') return { op: 'read' }; if (method === 'PUT' || method === 'POST') return { op: 'write' }; if (method === 'DELETE') return { op: hasAny(query, ['uploadId']) ? 'write' : 'delete' }; return { op: 'object-admin' }; } function hasAny(query, names) { return names.some((n) => Object.prototype.hasOwnProperty.call(query, n)); } // Decides a grant-key request. Returns { allow, needsAbsent } or { allow:false, reason }. // needsAbsent: an "add" grant may only create keys that do not exist yet. export function decideGrant(rule, bucket, op, key, listPrefix = '') { if (!live(rule)) return { allow: false, reason: 'this key has expired' }; if (bucket !== rule.bucket) return { allow: false, reason: 'this key is for another drive' }; if (op === 'list') { return listPrefix.startsWith(rule.prefix) ? { allow: true } : { allow: false, reason: `this key may only list ${rule.prefix || 'the drive'}` }; } if (!String(key).startsWith(rule.prefix)) return { allow: false, reason: `this key only reaches ${rule.prefix || 'the drive'}` }; if (op === 'read') return { allow: true }; if (op === 'write') { if (rule.rights === 'edit') return { allow: true }; if (rule.rights === 'add') return { allow: true, needsAbsent: true }; return { allow: false, reason: 'this key is read-only' }; } if (op === 'delete') return rule.rights === 'edit' ? { allow: true } : { allow: false, reason: 'this key may not delete' }; return { allow: false, reason: 'not allowed with a shared key' }; } // Longest live public rule covering bucket/key, or null. export function publicRuleFor(rules, bucket, key) { let best = null; for (const r of rules) { if (r.type !== 'public' || r.bucket !== bucket || !live(r) || !key.startsWith(r.prefix)) continue; if (!best || r.prefix.length > best.prefix.length) best = r; } return best; } // Rules as the owner sees them; grant secrets stay in (the owner made them). export function forOwner(rules) { return rules.filter((r) => live(r)); }