Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Detailed documentation is available in the `docs/` directory:
- [Database Documentation](docs/database/README.md) - Database structure and diagrams
- [Cron Jobs](docs/cron-jobs.md) - Scheduled tasks
- [Analytics](docs/analytics.md) - Event tracking and analytics
- [AWS S3 Guardrails](docs/security/aws-s3-guardrails.md) - IAM boundary and alert templates for S3 service users

## Development

Expand Down
110 changes: 110 additions & 0 deletions docs/security/aws-s3-bucket-change-alerts.cloudformation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "Alert on StudentHub S3 bucket-admin API calls captured by CloudTrail.",
"Parameters": {
"AlertSnsTopicArn": {
"Type": "String",
"Description": "SNS topic ARN for the maintainer-owned technical alert channel."
}
},
"Resources": {
"StudentHubS3BucketAdminChangeRule": {
"Type": "AWS::Events::Rule",
"Properties": {
"Description": "Alert when service users or automation change bucket-level S3 controls.",
"EventPattern": {
"source": [
"aws.s3"
],
"detail-type": [
"AWS API Call via CloudTrail"
],
"detail": {
"eventSource": [
"s3.amazonaws.com"
],
"eventName": [
"DeleteBucketCors",
"DeleteBucketPolicy",
"DeleteBucketReplication",
"DeleteBucketWebsite",
"DeletePublicAccessBlock",
"PutBucketAcl",
"PutBucketCors",
"PutBucketLifecycle",
"PutBucketLifecycleConfiguration",
"PutBucketLogging",
"PutBucketNotification",
"PutBucketPolicy",
"PutBucketReplication",
"PutBucketTagging",
"PutBucketVersioning",
"PutBucketWebsite",
"PutPublicAccessBlock"
],
"requestParameters": {
"bucketName": [
{
"prefix": "studenthub-"
}
]
}
}
},
"State": "ENABLED",
"Targets": [
{
"Arn": {
"Ref": "AlertSnsTopicArn"
},
"Id": "StudentHubS3BucketAdminAlertTopic"
}
]
}
},
"StudentHubS3BucketAdminAlertTopicPolicy": {
"Type": "AWS::SNS::TopicPolicy",
"Properties": {
"Topics": [
{
"Ref": "AlertSnsTopicArn"
}
],
"PolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEventBridgeBucketAdminAlerts",
"Effect": "Allow",
"Principal": {
"Service": "events.amazonaws.com"
},
"Action": "sns:Publish",
"Resource": {
"Ref": "AlertSnsTopicArn"
},
"Condition": {
"ArnEquals": {
"aws:SourceArn": {
"Fn::GetAtt": [
"StudentHubS3BucketAdminChangeRule",
"Arn"
]
}
}
}
}
]
}
}
}
},
"Outputs": {
"RuleName": {
"Description": "EventBridge rule name for S3 bucket-admin change alerts.",
"Value": {
"Ref": "StudentHubS3BucketAdminChangeRule"
}
}
}
}
36 changes: 36 additions & 0 deletions docs/security/aws-s3-guardrails.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# AWS S3 Service User Guardrails

This repo keeps application service users away from bucket-admin powers. Use these templates when creating or rotating StudentHub S3 service users after the Civil ID incident.

The templates are safe to review in GitHub because they contain no account IDs, ARNs for private users, access keys, secret keys, candidate data, or live bucket mutations.

## Files

- `docs/security/aws-s3-service-user-permissions-boundary.json` limits an S3 service user to object-level actions for the temp and permanent upload buckets, while explicitly denying bucket administration actions such as lifecycle, CORS, policy, logging, replication, public-access-block, website, ACL, and versioning changes.
- `docs/security/aws-s3-bucket-change-alerts.cloudformation.json` adds an EventBridge rule for CloudTrail S3 bucket-admin API calls and routes the events to an SNS topic provided by maintainers at deploy time.
- `tools/check-aws-s3-guardrails.mjs` validates that the templates keep the incident-sensitive denies, object-level allow list, and alert event coverage intact.

## Deploy Sequence

1. Provide the maintainer-owned `AlertSnsTopicArn` parameter outside this repo.
2. Create or update the SNS topic used for the technical alert channel.
3. Deploy `aws-s3-bucket-change-alerts.cloudformation.json` in the AWS account and region where CloudTrail management events are delivered.
4. Attach `aws-s3-service-user-permissions-boundary.json` as the permissions boundary for S3-only service users such as temp browser upload and permanent upload copy/delete users.
5. Keep identity policies narrower than the boundary. The boundary is a maximum allowed scope, not a replacement for per-user policies.
6. Tag each IAM user and key with `owner`, `service`, and `environment` so future CloudTrail events can be mapped to an accountable service.

## Validation

Run this before changing either template:

```bash
node tools/check-aws-s3-guardrails.mjs
```

The check fails if a protected bucket-admin action drops out of the boundary or EventBridge pattern, if an allow statement grants `s3:*`, or if an AWS access key shaped value is committed into these templates.

## Notes

- This does not rotate, deactivate, or delete any live keys.
- This does not enable versioning or change bucket policies directly.
- This does not replace a CloudTrail investigation. It gives maintainers a repeatable guardrail for the root-cause class: application service users should not be able to change lifecycle, CORS, policies, logging, replication, public access settings, or other bucket-level controls.
60 changes: 60 additions & 0 deletions docs/security/aws-s3-service-user-permissions-boundary.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowExpectedStudentHubBucketReadAccess",
"Effect": "Allow",
"Action": [
"s3:GetBucketLocation",
"s3:ListBucket",
"s3:ListBucketMultipartUploads"
],
"Resource": [
"arn:aws:s3:::studenthub-uploads",
"arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry"
]
},
{
"Sid": "AllowExpectedStudentHubObjectAccess",
"Effect": "Allow",
"Action": [
"s3:AbortMultipartUpload",
"s3:CopyObject",
"s3:DeleteObject",
"s3:GetObject",
"s3:ListMultipartUploadParts",
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": [
"arn:aws:s3:::studenthub-uploads/*",
"arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry/*"
]
},
{
"Sid": "DenyBucketAdministrationForServiceUsers",
"Effect": "Deny",
"Action": [
"s3:DeleteBucket",
"s3:DeleteBucketOwnershipControls",
"s3:DeleteBucketPolicy",
"s3:DeleteBucketWebsite",
"s3:PutBucketCORS",
"s3:PutBucketAcl",
"s3:PutBucketLogging",
"s3:PutBucketNotification",
"s3:PutBucketOwnershipControls",
"s3:PutBucketPolicy",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketVersioning",
"s3:PutBucketWebsite",
"s3:PutEncryptionConfiguration",
"s3:PutLifecycleConfiguration",
"s3:PutReplicationConfiguration"
],
"Resource": [
"arn:aws:s3:::studenthub-*"
]
}
]
}
118 changes: 118 additions & 0 deletions tools/check-aws-s3-guardrails.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const boundaryPath = resolve(
root,
"docs/security/aws-s3-service-user-permissions-boundary.json",
);
const alertsPath = resolve(
root,
"docs/security/aws-s3-bucket-change-alerts.cloudformation.json",
);

const boundaryText = readFileSync(boundaryPath, "utf8");
const alertsText = readFileSync(alertsPath, "utf8");
const boundary = JSON.parse(boundaryText);
const alertsTemplate = JSON.parse(alertsText);

const requiredDeniedActions = [
"s3:DeleteBucketPolicy",
"s3:PutBucketCORS",
"s3:PutBucketLogging",
"s3:PutBucketPolicy",
"s3:PutBucketPublicAccessBlock",
"s3:PutBucketVersioning",
"s3:PutLifecycleConfiguration",
"s3:PutReplicationConfiguration",
];

const requiredAlertEvents = [
"DeleteBucketCors",
"DeleteBucketPolicy",
"PutBucketCors",
"PutBucketLifecycle",
"PutBucketLifecycleConfiguration",
"PutBucketLogging",
"PutBucketPolicy",
"PutBucketReplication",
"PutBucketVersioning",
"PutPublicAccessBlock",
];

function asArray(value) {
return Array.isArray(value) ? value : [value];
}

function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}

const statements = asArray(boundary.Statement);
const denyActions = new Set(
statements
.filter((statement) => statement.Effect === "Deny")
.flatMap((statement) => asArray(statement.Action)),
);
const allowStatements = statements.filter((statement) => statement.Effect === "Allow");
const allowActions = new Set(allowStatements.flatMap((statement) => asArray(statement.Action)));
const allowResources = new Set(
allowStatements.flatMap((statement) => asArray(statement.Resource)),
);

for (const action of requiredDeniedActions) {
assert(denyActions.has(action), `Boundary is missing required deny: ${action}`);
}

for (const action of allowActions) {
assert(action !== "s3:*", "Boundary allow list must not include s3:*");
assert(!action.endsWith("*"), `Boundary allow action must be explicit: ${action}`);
}

assert(
allowResources.has("arn:aws:s3:::studenthub-uploads") &&
allowResources.has("arn:aws:s3:::studenthub-uploads/*"),
"Boundary must include permanent StudentHub upload bucket resources",
);
assert(
allowResources.has("arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry") &&
allowResources.has("arn:aws:s3:::studenthub-public-anyone-can-upload-24hr-expiry/*"),
"Boundary must include temp StudentHub upload bucket resources",
);

const rule =
alertsTemplate.Resources?.StudentHubS3BucketAdminChangeRule?.Properties?.EventPattern;
const eventNames = new Set(rule?.detail?.eventName ?? []);

for (const eventName of requiredAlertEvents) {
assert(eventNames.has(eventName), `EventBridge rule is missing event: ${eventName}`);
}

const alertTargets =
alertsTemplate.Resources?.StudentHubS3BucketAdminChangeRule?.Properties?.Targets ?? [];
assert(alertTargets.length > 0, "EventBridge rule must route alerts to a target");
assert(
JSON.stringify(alertTargets).includes("AlertSnsTopicArn"),
"EventBridge target must use the maintainer-provided SNS topic parameter",
);

const topicPolicy =
alertsTemplate.Resources?.StudentHubS3BucketAdminAlertTopicPolicy?.Properties?.PolicyDocument;
assert(topicPolicy, "Template must grant EventBridge permission to publish to the SNS topic");
assert(
JSON.stringify(topicPolicy).includes("events.amazonaws.com") &&
JSON.stringify(topicPolicy).includes("sns:Publish"),
"SNS topic policy must allow EventBridge to publish alerts",
);

const combinedTemplates = `${boundaryText}\n${alertsText}`;
assert(!/AKIA[0-9A-Z]{16}/.test(combinedTemplates), "Templates must not contain AWS access keys");
assert(
!/[0-9]{12}\.dkr\.ecr\./.test(combinedTemplates),
"Templates must not contain concrete AWS account IDs",
);

console.log("AWS S3 guardrail templates passed validation.");