feat(offsite-backup): olsitec-foundation bucket + scoped creds on home MinIO

CONTRACT_004 offsite target (ADR-004 'second self-hosted location'). @pulumi/minio
program (modeled on olsicloud4 modules/minio): bucket 'olsitec-foundation' +
scoped IAM user/policy + service account on minio.wob.olsitec.de:19000.

Verified: scoped SA can put/list/delete in its bucket, DENIED cross-bucket. Admin
creds + scoped creds via ENV/state only (gitignored), never committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 20:34:55 +02:00
parent 42f0aec52a
commit db47037bdc
8 changed files with 124 additions and 1 deletions

86
offsite-backup/index.ts Normal file
View file

@ -0,0 +1,86 @@
// offsite-backup/index.ts
//
// Offsite backup target (CONTRACT_004 §4.1; ADR-004 "second self-hosted location"):
// the `olsitec-foundation` bucket + a SCOPED service account on the home Synology
// DS923+ MinIO. The Helsinki foundation VM pushes backup bundles here with the
// scoped creds (never the MinIO root). Modeled on olsicloud4 modules/minio.
//
// Admin creds via ENV (export MINIO_BACKUP_USER/MINIO_BACKUP_PASSWORD from pass),
// never committed. Endpoint is the PUBLIC one (reachable from Hetzner).
import * as pulumi from "@pulumi/pulumi";
import * as minio from "@pulumi/minio";
const adminUser = process.env.MINIO_BACKUP_USER;
const adminPass = process.env.MINIO_BACKUP_PASSWORD;
if (!adminUser || !adminPass) {
throw new Error(
'MINIO_BACKUP_USER/MINIO_BACKUP_PASSWORD env required: export from pass olsicloud4/MINIO_BACKUP_*',
);
}
const endpoint = "minio.wob.olsitec.de:19000"; // public S3 API (-> 62.176.248.112)
const bucketName = "olsitec-foundation";
const provider = new minio.Provider("home-minio", {
minioServer: endpoint,
minioUser: adminUser,
minioPassword: adminPass,
minioSsl: true,
});
const popts = { provider };
const bucket = new minio.S3Bucket(
"foundation-backup-bucket",
{
bucket: bucketName,
acl: "private",
forceDestroy: false, // holds backups — never silently wipe on destroy
},
popts,
);
// Scoped policy: full object ops on this bucket only.
const policy = new minio.IamPolicy(
"foundation-backup-policy",
{
name: "olsitec-foundation-backup",
policy: bucket.bucket.apply((b) =>
JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: ["s3:ListBucket", "s3:GetBucketLocation", "s3:ListBucketMultipartUploads"],
Resource: [`arn:aws:s3:::${b}`],
},
{
Effect: "Allow",
Action: ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListMultipartUploadParts", "s3:AbortMultipartUpload"],
Resource: [`arn:aws:s3:::${b}/*`],
},
],
}),
),
},
popts,
);
const user = new minio.IamUser("foundation-backup-user", { name: "olsitec-foundation-backup" }, popts);
new minio.IamUserPolicyAttachment(
"foundation-backup-user-policy",
{ userName: user.name, policyName: policy.name },
{ ...popts, dependsOn: [bucket, policy, user] },
);
// Service account = the scoped access/secret key pair the VM actually uses.
const sa = new minio.IamServiceAccount(
"foundation-backup-sa",
{ targetUser: user.name },
{ ...popts, dependsOn: [user] },
);
export const offsiteEndpoint = `https://${endpoint}`;
export const offsiteBucket = bucket.bucket;
export const offsiteAccessKey = pulumi.secret(sa.accessKey);
export const offsiteSecretKey = pulumi.secret(sa.secretKey);