Skip to content
Merged
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
72 changes: 64 additions & 8 deletions docs/query-workgroup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,68 @@ a role narrower than `AdministratorAccess`.

## Permissions for running a query

The deploy role has no part in this one. Whoever runs the query carries it, and Athena reads the
source data and writes the results as them, so both buckets belong here alongside the workgroup.
The deploy role has no part in this one. Whoever runs the query carries it. Athena plans the query,
reads the log objects and writes the answer as that caller, and the catalog and both buckets
therefore belong here alongside the workgroup.

### The four actions `ReadOnlyAccess` denies

A caller already holding the AWS managed `ReadOnlyAccess` policy is four actions short of running a
query.

- `athena:StartQueryExecution` on the workgroup
- `athena:StopQueryExecution` on the workgroup
- `s3:PutObject` on the results bucket
- `s3:AbortMultipartUpload` on the results bucket

Measured on 2026-08-28 with `aws iam simulate-principal-policy`, against an `AWSReservedSSO_ReadOnly`
role carrying `ReadOnlyAccess`. Every other action on this page came back `allowed`, including
`athena:GetWorkGroup`, `athena:ListNamedQueries`, `athena:BatchGetNamedQuery`, the three Glue reads
and the S3 reads on both buckets.

Those four cannot be dropped. A query is a job somebody starts, and its answer is an object Athena
writes under the caller's own identity. Both of those are writes, and Athena SQL has no read-only
path. Reading a [precomputed summary](../summaries/) does, which is the read path the named commands
use.

### Granting it from CDK

```typescript
const queries = new QueryWorkgroup(this, "RainlyticsQueries");
const table = new LogTable(this, "RainlyticsTable", { deliveries: [delivery] });

queries.grantQuerying(role, table);
```

One line per identity. It attaches the Athena actions on this workgroup, the Glue reads on the
catalog, the database and the table, the reads on the log bucket, and the reads and writes on the
results bucket. Every ARN it names belongs to this deployment, and a bucket encrypted with a
customer key hands out its own `kms:Decrypt` through the same call.

The table is passed because a workgroup is not tied to one. Two tables queried in the same workgroup
are two calls, and an identity that should reach only one of them gets one.

Reading the summaries is a second call, covered on the [summaries](../summaries/) page:

```typescript
summaries.grantReadingSummaries(role);
```

### The same policy written out

For an identity built outside CDK. This is the list `grantQuerying` attaches.

```typescript
new PolicyStatement({
sid: "RunningRainlyticsQueries",
actions: [
"athena:StartQueryExecution",
"athena:StopQueryExecution",
"athena:GetQueryExecution",
"athena:GetQueryResults",
"athena:StopQueryExecution",
"athena:GetWorkGroup",
"athena:ListNamedQueries",
"athena:BatchGetNamedQuery",
],
resources: [`arn:aws:athena:${region}:${account}:workgroup/${workgroupName}`],
});
Expand All @@ -195,12 +246,17 @@ With `glue:GetDatabase`, `glue:GetTable` and `glue:GetPartitions` on the
`s3:ListBucket`, `s3:GetBucketLocation`, `s3:ListBucketMultipartUploads`,
`s3:ListMultipartUploadParts` and `s3:AbortMultipartUpload`.

The multipart actions earn their place. Athena uploads a large result in parts, and this is the
list AWS documents for a query results bucket. `s3:GetObject` on results is what reads
the answer back, which `GetQueryResults` does on the caller's behalf.
Athena reads the workgroup's own configuration on the way to running a query in it, and refuses the
query without `athena:GetWorkGroup`. The two named-query actions are what `rainlytics saved-query`
runs on. Athena answers `ListNamedQueries` with ids alone, and a name is found by reading them.

The multipart actions earn their place. Athena uploads a large result in parts, and this is the list
AWS documents for a query results bucket. `s3:GetObject` on results is what reads the answer back,
and `GetQueryResults` does that on the caller's behalf.

This list comes from AWS's documentation rather than from a deploy. Nobody has yet run a Rainlytics
query under a policy narrower than the one their SSO role already carries.
The four in the delta above were measured. The rest of the list comes from AWS's documentation and
from what the `rainlytics` command sends. Nobody has yet run a Rainlytics query under a policy
narrower than the one their SSO role already carries.

<!-- card
```typescript
Expand Down
18 changes: 18 additions & 0 deletions docs/summaries/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,24 @@ fresher answer.
rainlytics pageviews --last 7d --summaries rainlytics-summaries-1a2b
```

### What a reader has to be allowed

`s3:GetObject` on the bucket's objects, and nothing else. Every key comes from the question and the
window, and nothing lists the bucket.

A caller holding the AWS managed `ReadOnlyAccess` policy reads summaries with no policy change.
That read came back `allowed` from `aws iam simulate-principal-policy` on 2026-08-28, against an
`AWSReservedSSO_ReadOnly` role carrying `ReadOnlyAccess`. Running a query is the other case, and
the [query workgroup](../query-workgroup/) page has the four actions that policy denies.

An identity built narrower than that takes the read from CDK:

```typescript
summaries.grantReadingSummaries(role);
```

A bucket kept under a customer key hands out its own `kms:Decrypt` through the same call.

### Which windows a range covers

A range arrives from `--last` and lands wherever the clock happens to be. The windows on S3 sit on
Expand Down
1 change: 1 addition & 0 deletions src/cdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
smallestBytesScannedCutoff,
} from "./query-cost.js";
export { QueryWorkgroup, type QueryWorkgroupProps } from "./query-workgroup.js";
export type { QueryResultsBucket } from "./query-results-bucket.js";
export {
RollupQueries,
type RollupQueriesProps,
Expand Down
18 changes: 18 additions & 0 deletions src/cdk/query-results-bucket.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Where Athena puts what a query answered.

import type { IKey } from "aws-cdk-lib/aws-kms";
import {
BlockPublicAccess,
Bucket,
Expand All @@ -12,6 +13,23 @@ import type { Construct } from "constructs";
import { defaultResultsRetention } from "./query-cost.js";
import type { QueryWorkgroupProps } from "./query-workgroup.js";

/**
* What a policy statement needs of the results bucket, which is an ARN and
* whatever key encrypts it.
*
* Narrower than `IBucket`, for the reason `LogDeliveryBucket` sets out. The
* bucket below is created with S3-managed keys and has no key to hand out.
* The shape carries one anyway, so a statement over a results bucket
* somebody else made reads the same way the log bucket's does.
*/
export interface QueryResultsBucket {
/** The bucket's ARN, which the statement is scoped to. */
readonly bucketArn: string;

/** The key encrypting it, where it is encrypted with one. */
readonly encryptionKey?: IKey | undefined;
}

/**
* The bucket Athena writes a query's results into.
*
Expand Down
125 changes: 124 additions & 1 deletion src/cdk/query-workgroup.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { gzipSync } from "node:zlib";

import { faker } from "@faker-js/faker";
import { Match, Template } from "aws-cdk-lib/assertions";
import { Distribution } from "aws-cdk-lib/aws-cloudfront";
import { HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
import { Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Key } from "aws-cdk-lib/aws-kms";
import { App, CfnOutput, Duration, Size, Stack } from "aws-cdk-lib/core";
import { describe, expect, it } from "vitest";

Expand All @@ -15,7 +18,7 @@ import {
} from "../dataset.js";
import { partitionPrefix } from "../partitions.js";
import { CloudFrontLogDelivery } from "./log-delivery.js";
import { LogBucket } from "./log-bucket.js";
import { LogBucket, type LogBucketProps } from "./log-bucket.js";
import { LogTable } from "./log-table.js";
import { defaultBytesScannedCutoff } from "./query-cost.js";
import { QueryWorkgroup, type QueryWorkgroupProps } from "./query-workgroup.js";
Expand Down Expand Up @@ -274,6 +277,126 @@ describe("the workgroup a Rainlytics query runs in", () => {
expect(building).toThrow(/10000000/u);
});

describe("what an identity granted querying can reach", () => {
/**
* A whole deployment in one stack, with a role handed the grant.
*
* Synthesised rather than deployed. IAM is the part of this a simulated
* account cannot prove, for the reason `summary-permissions.test.ts`
* gives, so these cases read the policy the grant wrote.
*/
const grantedTo = (
logEncryption: (stack: Stack) => LogBucketProps = () => ({}),
): { readonly stack: Stack; readonly logBucketName: string } => {
const stack = new Stack(new App(), "AnalyticsStack", {
env: { account: "123456789012", region: "us-east-1" },
});
const logBucketName = `rainlytics-logs-${faker.string.uuid()}`;
const logs = new LogBucket(stack, "RainlyticsLogs", {
bucketName: logBucketName,
...logEncryption(stack),
});
const delivery = new CloudFrontLogDelivery(stack, "RainlyticsDelivery", {
distributionId: "E1EXAMPLE1234",
logBucket: logs.bucket,
});
const logTable = new LogTable(stack, "RainlyticsTable", {
deliveries: [delivery],
});
const queries = new QueryWorkgroup(stack, "RainlyticsQueries", {
resultsBucketName: `rainlytics-results-${faker.string.uuid()}`,
});

queries.grantQuerying(
new Role(stack, "Analyst", {
assumedBy: new ServicePrincipal("lambda.amazonaws.com"),
}),
logTable,
);

return { stack, logBucketName };
};

/** One allow statement as CloudFormation carries it. */
interface WrittenStatement {
readonly Action: string | string[];
readonly Resource: unknown;
}

/** The statements the granted role's own policy carries. */
const statementsIn = (stack: Stack): readonly WrittenStatement[] => {
const policies = Template.fromStack(stack).findResources(
"AWS::IAM::Policy",
{
Properties: { Roles: [{ Ref: Match.stringLikeRegexp("^Analyst") }] },
},
) as Record<
string,
{ Properties: { PolicyDocument: { Statement: WrittenStatement[] } } }
>;

return Object.values(policies).flatMap(
(policy) => policy.Properties.PolicyDocument.Statement,
);
};

/** Every action those statements allow. */
const allowed = (stack: Stack): readonly string[] =>
statementsIn(stack).flatMap((statement) => [statement.Action].flat());

it("reaches every service one query touches", () => {
// Given a role a site handed the grant.
const { stack } = grantedTo();

// When the stack it was granted in is synthesised.
const actions = allowed(stack);

// Then it holds all four halves of a query. Athena starts it, Glue
// plans it, the log bucket answers it and the results bucket takes the
// answer, and Athena does the last three as the caller rather than as
// itself.
expect(actions).toContain("athena:StartQueryExecution");
expect(actions).toContain("glue:GetPartitions");
expect(actions).toContain("s3:GetObject");
expect(actions).toContain("s3:PutObject");
// And it can look up a query the site saved, which is what
// `rainlytics saved-query` runs.
expect(actions).toContain("athena:BatchGetNamedQuery");
});

it("names this deployment's resources and never a wildcard", () => {
// Given a role granted querying over one deployment.
const { stack } = grantedTo();

// When the resources the grant wrote are read back.
const resources = statementsIn(stack).map((statement) =>
JSON.stringify(statement.Resource),
);

// Then it names this workgroup, this database and this table. A grant
// reaching `*` would answer the case above and still be wrong, and the
// account holds analytics for every site the maintainer runs.
expect(resources.join(",")).toContain("workgroup/rainlytics");
expect(resources.join(",")).toContain("table/rainlytics/cloudfront_logs");
expect(resources.filter((each) => each.includes('"*"'))).toStrictEqual(
[],
);
});

it("decrypts a log bucket a site keeps under its own key", () => {
// Given a deployment whose logs are encrypted with a customer key
// rather than with S3-managed encryption.
const { stack } = grantedTo((inStack) => ({
encryptionKey: new Key(inStack, "LogKey"),
}));

// Then the grantee can decrypt what it reads. S3 answers a GetObject
// under a key the caller cannot use with an AccessDenied from KMS, and
// the S3 statement has nothing to say about that.
expect(allowed(stack)).toContain("kms:Decrypt");
});
});

const table = (): string => qualifiedTableName();

/**
Expand Down
45 changes: 45 additions & 0 deletions src/cdk/query-workgroup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { CfnWorkGroup } from "aws-cdk-lib/aws-athena";
import type { IGrantable } from "aws-cdk-lib/aws-iam";
import type { Bucket } from "aws-cdk-lib/aws-s3";
import type { Duration, RemovalPolicy, Size } from "aws-cdk-lib/core";
import { Construct } from "constructs";

import { defaultWorkgroupName } from "../dataset.js";
import type { LogTable } from "./log-table.js";
import { assertUsableCutoff, defaultBytesScannedCutoff } from "./query-cost.js";
import { queryResultsBucket } from "./query-results-bucket.js";
import {
athenaStatements,
catalogStatements,
logReadStatements,
resultsStatements,
savedQueryStatements,
} from "./summary-permissions.js";

/** What a Rainlytics query workgroup can be told. */
export interface QueryWorkgroupProps {
Expand Down Expand Up @@ -148,4 +157,40 @@ export class QueryWorkgroup extends Construct {
},
});
}

/**
* Lets an identity run Rainlytics queries against one table.
*
* ```typescript
* queries.grantQuerying(role, table);
* ```
*
* Athena is only a third of it. A query is planned from the Glue catalog,
* reads the log objects and writes its answer to the results bucket, and
* all three happen as whoever started the query rather than as Athena. So
* this covers the workgroup, the catalog, the database, the table, the log
* bucket and the results bucket, and the grantee holds no permission on any
* resource outside this deployment.
*
* The table is passed rather than read off the workgroup because a
* workgroup is not tied to one. Two tables queried in one workgroup are two
* calls, and a grantee that should reach only one of them gets only one.
*
* Either bucket encrypted with a customer key hands out its own
* `kms:Decrypt` here as well.
*
* `docs/query-workgroup/` writes the same permissions out as a policy, for
* an identity built outside CDK.
*/
grantQuerying(grantee: IGrantable, table: LogTable): void {
for (const statement of [
...athenaStatements(this, this.workgroupName),
...savedQueryStatements(this, this.workgroupName),
...catalogStatements(this, table.dataset),
...logReadStatements(table.logBucket, grantee),
...resultsStatements(this.resultsBucket, grantee),
]) {
grantee.grantPrincipal.addToPrincipalPolicy(statement);
}
}
}
Loading