diff --git a/.env.example b/.env.example index cfd43bf..ff4431e 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ -# s3 or local +# required: comma separated list of valid tokens (do not include spaces) eg. "token1,token2,token3" +AUTH_TOKENS= + +# required: 's3' or 'local' STORAGE_PROVIDER= -# required if provider is local +# required if provider is local eg. blobs will point to /garden-snail/blobs in the container LOCAL_STORAGE_PATH= # required if provider is s3 diff --git a/README.md b/README.md index 9dc15fa..0bd0dc2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,10 @@ node dist/main ### Environment variables ```sh -# Set it to 's3' or 'local' +# required: comma separated list of valid tokens +AUTH_TOKENS= + +# required: 's3' or 'local' STORAGE_PROVIDER= # Required if provider is local @@ -41,15 +44,14 @@ S3_ENDPOINT= ## Notes -The `1.1.0` release is working and is compatible with the latest turborepo releases. Check the integration tests on the latest [workflow run](https://github.com/pkarolyi/garden-snail/actions/). +Check the integration tests on the [workflow runs](https://github.com/pkarolyi/garden-snail/actions/) for a given tag to check for compatibility. -This version **does not include any authorization or rate limiting functionality**. It is intended for internal deployments in organizations with external access controls. +The `1.1.0` release and releases prior to that **do not include any authorization or rate limiting functionality**. ## Roadmap These are the things I will be working on in the coming weeks in no particular order: -- Authorization - Rate limiting - More providers - Based on requests diff --git a/compose.yml b/compose.yml index 5dd7c6d..1cf5d6d 100644 --- a/compose.yml +++ b/compose.yml @@ -7,6 +7,7 @@ services: cache_to: - type=gha,mode=max environment: + - AUTH_TOKENS=TEST_TOKEN_NOT_SECRET - STORAGE_PROVIDER=local - LOCAL_STORAGE_PATH=blobs ports: diff --git a/src/artifacts/artifacts.controller.ts b/src/artifacts/artifacts.controller.ts index 25567c7..6eb51c7 100644 --- a/src/artifacts/artifacts.controller.ts +++ b/src/artifacts/artifacts.controller.ts @@ -11,13 +11,16 @@ import { Put, Query, StreamableFile, + UseGuards, } from "@nestjs/common"; import { StorageService } from "src/storage/storage.service"; import { Readable } from "stream"; +import { ArtifactsGuard } from "./artifacts.guard"; import { GetArtifactRO, PutArtifactRO, StatusRO } from "./artifacts.interface"; import { ArtifactQueryTeamPipe } from "./artifacts.pipe"; @Controller({ path: "artifacts", version: "8" }) +@UseGuards(ArtifactsGuard) export class ArtifactsController { private readonly logger = new Logger(ArtifactsController.name); diff --git a/src/artifacts/artifacts.guard.ts b/src/artifacts/artifacts.guard.ts new file mode 100644 index 0000000..da6f271 --- /dev/null +++ b/src/artifacts/artifacts.guard.ts @@ -0,0 +1,24 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { FastifyRequest } from "fastify"; +import { ConfigurationSchema } from "src/config/configuration"; + +@Injectable() +export class ArtifactsGuard implements CanActivate { + constructor( + private readonly configService: ConfigService, + ) {} + + canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) return false; + + // eg. "Bearer token123" + const token = authHeader.split(" ")[1]; + const authConfig = this.configService.get("auth", { infer: true }); + + return authConfig.tokens.includes(token); + } +} diff --git a/src/config/configuration.spec.ts b/src/config/configuration.spec.ts index ba7aafa..fe309ce 100644 --- a/src/config/configuration.spec.ts +++ b/src/config/configuration.spec.ts @@ -2,11 +2,13 @@ import { describe, expect, it } from "vitest"; import { validate } from "./configuration"; const validConfigurationLocal = { + AUTH_TOKENS: "token1,token2", STORAGE_PROVIDER: "local", LOCAL_STORAGE_PATH: "path", }; const validConfigurationS3 = { + AUTH_TOKENS: "token1,token2", STORAGE_PROVIDER: "s3", S3_BUCKET: "bucket", S3_ACCESS_KEY_ID: "accessKeyId", @@ -14,6 +16,7 @@ const validConfigurationS3 = { S3_SESSION_TOKEN: "sessionToken", S3_REGION: "region", S3_FORCE_PATH_STYLE: "true", + S3_ENDPOINT: "endpoint", }; describe("Configuration", () => { @@ -33,6 +36,9 @@ describe("Configuration", () => { it("should transform the configuration", () => { const configuration = validate(validConfigurationLocal); expect(configuration).toEqual({ + auth: { + tokens: validConfigurationLocal.AUTH_TOKENS.split(","), + }, storage: { provider: validConfigurationLocal.STORAGE_PROVIDER, basePath: validConfigurationLocal.LOCAL_STORAGE_PATH, @@ -57,6 +63,9 @@ describe("Configuration", () => { it("should transform the configuration", () => { const configuration = validate(validConfigurationS3); expect(configuration).toEqual({ + auth: { + tokens: validConfigurationS3.AUTH_TOKENS.split(","), + }, storage: { provider: validConfigurationS3.STORAGE_PROVIDER, bucket: validConfigurationS3.S3_BUCKET, @@ -66,7 +75,8 @@ describe("Configuration", () => { sessionToken: validConfigurationS3.S3_SESSION_TOKEN, }, region: validConfigurationS3.S3_REGION, - forcePathStyle: Boolean(validConfigurationS3.S3_FORCE_PATH_STYLE), + endpoint: validConfigurationS3.S3_ENDPOINT, + forcePathStyle: validConfigurationS3.S3_FORCE_PATH_STYLE === "true", }, }); }); diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 99b5837..13ec325 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,32 +1,40 @@ import { z } from "zod"; +const splitComma = (s: string) => s.split(","); + const configurationSchema = z - .union([ - z.object({ - STORAGE_PROVIDER: z.literal("s3"), - S3_BUCKET: z.string(), - S3_ACCESS_KEY_ID: z.string(), - S3_SECRET_ACCESS_KEY: z.string(), - S3_SESSION_TOKEN: z.string().optional(), - S3_REGION: z.string().default("us-east-1"), - S3_FORCE_PATH_STYLE: z.coerce.boolean().default(false), - S3_ENDPOINT: z.string().optional(), - }), + .intersection( z.object({ - STORAGE_PROVIDER: z.literal("local"), - LOCAL_STORAGE_PATH: z.string(), + AUTH_TOKENS: z + .string() + .transform(splitComma) + .pipe(z.string().min(1).array()), }), - ]) - .transform((data) => - data.STORAGE_PROVIDER === "local" - ? { - storage: { + z.union([ + z.object({ + STORAGE_PROVIDER: z.literal("s3"), + S3_BUCKET: z.string(), + S3_ACCESS_KEY_ID: z.string(), + S3_SECRET_ACCESS_KEY: z.string(), + S3_SESSION_TOKEN: z.string().optional(), + S3_REGION: z.string().default("us-east-1"), + S3_FORCE_PATH_STYLE: z.enum(["true", "false"]).default("false"), + S3_ENDPOINT: z.string().optional(), + }), + z.object({ + STORAGE_PROVIDER: z.literal("local"), + LOCAL_STORAGE_PATH: z.string(), + }), + ]), + ) + .transform((data) => { + const storage = + data.STORAGE_PROVIDER === "local" + ? { provider: data.STORAGE_PROVIDER, basePath: data.LOCAL_STORAGE_PATH, - }, - } - : { - storage: { + } + : { provider: data.STORAGE_PROVIDER, bucket: data.S3_BUCKET, credentials: { @@ -35,11 +43,14 @@ const configurationSchema = z sessionToken: data.S3_SESSION_TOKEN, }, region: data.S3_REGION, - forcePathStyle: data.S3_FORCE_PATH_STYLE, + forcePathStyle: data.S3_FORCE_PATH_STYLE === "true", endpoint: data.S3_ENDPOINT, - }, - }, - ); + }; + return { + storage, + auth: { tokens: data.AUTH_TOKENS }, + }; + }); export type ConfigurationSchema = z.infer; diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 58cf2c1..0c3e36e 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -33,6 +33,7 @@ describe("AppController (e2e)", () => { const result = await app.inject({ method: "GET", url: "/v8/artifacts/status", + headers: { authorization: "Bearer token" }, }); expect(result.statusCode).toEqual(200); expect(result.json()).toEqual({ status: "enabled" }); diff --git a/test/vitest.config.e2e.ts b/test/vitest.config.e2e.ts index 254fe5f..feb1d85 100644 --- a/test/vitest.config.e2e.ts +++ b/test/vitest.config.e2e.ts @@ -7,6 +7,7 @@ export default defineConfig({ globals: true, root: "./", env: { + AUTH_TOKENS: "token", STORAGE_PROVIDER: "local", LOCAL_STORAGE_PATH: "blobs", }, diff --git a/vitest.config.ts b/vitest.config.ts index 1b0340a..1db703a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ globals: true, root: "./", env: { + AUTH_TOKENS: "token", STORAGE_PROVIDER: "local", LOCAL_STORAGE_PATH: "blobs", },