diff --git a/.github/workflows/promote-prod.yml b/.github/workflows/promote-prod.yml index 65cd94c..483e8cd 100644 --- a/.github/workflows/promote-prod.yml +++ b/.github/workflows/promote-prod.yml @@ -31,16 +31,36 @@ jobs: sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 sudo chmod +x /usr/local/bin/yq - - name: Update prod overlay image tags + - name: Update prod overlay image tags and base Job names env: TAG: ${{ inputs.tag }} run: | cd /tmp/infra PROD_KUST="apps/groombook/overlays/prod/kustomization.yaml" + + SHORT_SHA="${TAG##*-}" + export SHORT_SHA + export TAG + yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$PROD_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$PROD_KUST" + + # Update migrate Job name to include short SHA (immutable template fix) + MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + if [ -f "$MIGRATE_JOB" ]; then + yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" + fi + + # Update seed Job name to include short SHA (immutable template fix) + SEED_JOB="apps/groombook/base/seed-job.yaml" + if [ -f "$SEED_JOB" ]; then + yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + fi + git -C /tmp/infra diff --stat - name: Create PR on groombook/infra @@ -52,7 +72,7 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "release/promote-prod-${TAG}" - git add apps/groombook/overlays/prod/ + git add apps/groombook/overlays/prod/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml git commit -m "release: promote ${TAG} to production" git push -u origin "release/promote-prod-${TAG}" gh pr create \ @@ -60,4 +80,16 @@ jobs: --base main \ --head "release/promote-prod-${TAG}" \ --title "release: promote ${TAG} to production" \ - --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood" \ No newline at end of file + --body "Promote image tag ${TAG} to production after UAT sign-off. cc @cpfarhood" + + - name: Notify on failure + if: failure() + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '## Production Promotion Failed\n\nThe `promote-prod` workflow failed. Check the workflow run logs for details.' + }); diff --git a/.github/workflows/promote-to-uat.yml b/.github/workflows/promote-to-uat.yml index 587e749..a1a79d4 100644 --- a/.github/workflows/promote-to-uat.yml +++ b/.github/workflows/promote-to-uat.yml @@ -32,7 +32,7 @@ jobs: sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 sudo chmod +x /usr/local/bin/yq - - name: Update UAT overlay image tags + - name: Update UAT overlay image tags and base Job names env: TAG: ${{ inputs.image_tag }} run: | @@ -45,11 +45,29 @@ jobs: exit 1 fi + SHORT_SHA="${TAG##*-}" + export SHORT_SHA + export TAG + yq -i '(.images[] | select(.name == "ghcr.io/groombook/api")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/web")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/migrate")).newTag = env(TAG)' "$UAT_KUST" yq -i '(.images[] | select(.name == "ghcr.io/groombook/seed")).newTag = env(TAG)' "$UAT_KUST" + # Update migrate Job name to include short SHA (immutable template fix) + MIGRATE_JOB="apps/groombook/base/migrate-job.yaml" + if [ -f "$MIGRATE_JOB" ]; then + yq -i '.metadata.name = "migrate-schema-" + env(SHORT_SHA)' "$MIGRATE_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$MIGRATE_JOB" + fi + + # Update seed Job name to include short SHA (immutable template fix) + SEED_JOB="apps/groombook/base/seed-job.yaml" + if [ -f "$SEED_JOB" ]; then + yq -i '.metadata.name = "seed-test-data-" + env(SHORT_SHA)' "$SEED_JOB" + yq -i '.metadata.annotations."groombook.app/deploy-version" = env(TAG)' "$SEED_JOB" + fi + git -C /tmp/infra diff --stat - name: Create PR on groombook/infra @@ -61,7 +79,7 @@ jobs: git config user.name "groombook-engineer[bot]" git config user.email "3141748+groombook-engineer[bot]@users.noreply.github.com" git checkout -b "chore/update-uat-image-tags-${TAG}" - git add apps/groombook/overlays/uat/ + git add apps/groombook/overlays/uat/ apps/groombook/base/migrate-job.yaml apps/groombook/base/seed-job.yaml git commit -m "chore: promote ${TAG} to UAT" git push -u origin "chore/update-uat-image-tags-${TAG}" diff --git a/apps/api/src/__tests__/crypto.test.ts b/apps/api/src/__tests__/crypto.test.ts index 36663f3..2602264 100644 --- a/apps/api/src/__tests__/crypto.test.ts +++ b/apps/api/src/__tests__/crypto.test.ts @@ -24,11 +24,11 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); - it("produces output in iv:ciphertext:authTag format", () => { + it("produces output in salt:iv:ciphertext:authTag format", () => { const encrypted = encryptSecret("test"); const parts = encrypted.split(":"); - expect(parts).toHaveLength(3); + expect(parts).toHaveLength(4); // Each part should be valid base64 parts.forEach((part) => { expect(() => Buffer.from(part, "base64")).not.toThrow(); @@ -62,11 +62,12 @@ describe("encryptSecret / decryptSecret", () => { it("throws when decrypting invalid format (wrong number of parts)", () => { const encrypted = encryptSecret("test"); - // Replace the last ":authTag" part by matching colon + non-colon chars at the end - const invalid = encrypted.replace(/:[^:]+$/, ""); + // Replace the last two parts with a single part to create a 2-part string + // This can't be parsed as either legacy (3 parts) or new (4 parts) format + const invalid = encrypted.replace(/:[^:]+$/, "").replace(/:[^:]+$/, ""); expect(() => decryptSecret(invalid)).toThrow( - "Invalid encrypted value format: expected iv:ciphertext:authTag" + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" ); }); @@ -93,4 +94,4 @@ describe("encryptSecret / decryptSecret", () => { expect(decrypted).toBe(plaintext); }); -}); \ No newline at end of file +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2d93fbd..e663986 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -167,7 +167,6 @@ api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); api.route("/admin/auth-provider", authProviderRouter); api.route("/admin/seed", adminSeedRouter); -api.route("/admin/auth-provider", authProviderRouter); api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); diff --git a/apps/api/src/middleware/rbac.ts b/apps/api/src/middleware/rbac.ts index b8473e8..786fdbc 100644 --- a/apps/api/src/middleware/rbac.ts +++ b/apps/api/src/middleware/rbac.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono"; -import { eq, getDb, staff } from "@groombook/db"; +import { and, eq, getDb, isNull, staff } from "@groombook/db"; export type StaffRole = "groomer" | "receptionist" | "manager"; export type StaffRow = typeof staff.$inferSelect; @@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler = async ( .from(staff) .where(eq(staff.oidcSub, jwt.sub)); if (!fallbackRow) { + // Auto-link: staff record exists with matching email but no userId — link it now + if (jwt.email) { + const [linkedStaff] = await db + .select() + .from(staff) + .where(and(eq(staff.email, jwt.email), isNull(staff.userId))); + if (linkedStaff) { + await db + .update(staff) + .set({ userId: jwt.sub }) + .where(eq(staff.id, linkedStaff.id)); + console.log( + `[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}` + ); + c.set("staff", linkedStaff); + await next(); + return; + } + } return c.json( { error: "Forbidden: no staff record found for authenticated user" }, 403 diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 066b753..7beaaa5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -40,6 +40,9 @@ export default defineConfig({ }, workbox: { globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"], + navigateFallbackDenylist: [ + /^\/api\/auth\/oauth2\/callback\//, + ], runtimeCaching: [ { urlPattern: /^http.*\/api\/.*/i, diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts index 371c3d3..541d5a3 100644 --- a/packages/db/src/crypto.ts +++ b/packages/db/src/crypto.ts @@ -7,18 +7,15 @@ const SALT_LENGTH = 16; /** * Derives a 32-byte key from BETTER_AUTH_SECRET using scrypt. - * BETTER_AUTH_SECRET is used as the password, with a fixed salt derived from the package name. + * A unique random salt is generated per encryptSecret() call and prepended to the output. */ -function deriveKey(secret: string): Buffer { - // Use a fixed salt derived from the package name for key derivation - // This gives us stable key derivation without storing an extra salt - const packageSalt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); - return scryptSync(secret, packageSalt, 32); +function deriveKey(secret: string, salt: Buffer): Buffer { + return scryptSync(secret, salt, 32); } /** * Encrypts a plaintext string using AES-256-GCM. - * Returns a base64-encoded string in the format: iv:ciphertext:authTag + * Returns a base64-encoded string in the format: salt:iv:ciphertext:authTag */ export function encryptSecret(plaintext: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -26,7 +23,8 @@ export function encryptSecret(plaintext: string): string { throw new Error("BETTER_AUTH_SECRET environment variable is required"); } - const key = deriveKey(secret); + const salt = randomBytes(SALT_LENGTH); + const key = deriveKey(secret, salt); const iv = randomBytes(IV_LENGTH); const cipher = createCipheriv(ALGORITHM, key, iv, { @@ -38,8 +36,9 @@ export function encryptSecret(plaintext: string): string { const authTag = cipher.getAuthTag(); - // Format: base64(iv):base64(ciphertext):base64(authTag) + // Format: base64(salt):base64(iv):base64(ciphertext):base64(authTag) return [ + salt.toString("base64"), iv.toString("base64"), ciphertext.toString("base64"), authTag.toString("base64"), @@ -48,7 +47,7 @@ export function encryptSecret(plaintext: string): string { /** * Decrypts a ciphertext string produced by encryptSecret. - * Expects the format: iv:ciphertext:authTag (all base64-encoded) + * Supports both new format (salt:iv:ciphertext:authTag) and legacy format (iv:ciphertext:authTag). */ export function decryptSecret(encrypted: string): string { const secret = process.env.BETTER_AUTH_SECRET; @@ -57,18 +56,31 @@ export function decryptSecret(encrypted: string): string { } const parts = encrypted.split(":"); - if (parts.length !== 3) { - throw new Error("Invalid encrypted value format: expected iv:ciphertext:authTag"); + + let salt: Buffer; + let iv: Buffer; + let ciphertext: Buffer; + let authTag: Buffer; + + if (parts.length === 4) { + // New format: salt:iv:ciphertext:authTag + salt = Buffer.from(parts[0]!, "base64"); + iv = Buffer.from(parts[1]!, "base64"); + ciphertext = Buffer.from(parts[2]!, "base64"); + authTag = Buffer.from(parts[3]!, "base64"); + } else if (parts.length === 3) { + // Legacy format: iv:ciphertext:authTag — use fixed package salt + salt = scryptSync("groombook-auth-provider-config", "", SALT_LENGTH); + iv = Buffer.from(parts[0]!, "base64"); + ciphertext = Buffer.from(parts[1]!, "base64"); + authTag = Buffer.from(parts[2]!, "base64"); + } else { + throw new Error( + "Invalid encrypted value format: expected salt:iv:ciphertext:authTag or iv:ciphertext:authTag" + ); } - const ivBase64 = parts[0]!; - const ciphertextBase64 = parts[1]!; - const authTagBase64 = parts[2]!; - const iv = Buffer.from(ivBase64, "base64"); - const ciphertext = Buffer.from(ciphertextBase64, "base64"); - const authTag = Buffer.from(authTagBase64, "base64"); - - const key = deriveKey(secret); + const key = deriveKey(secret, salt); const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH, @@ -79,4 +91,4 @@ export function decryptSecret(encrypted: string): string { plaintext = Buffer.concat([plaintext, decipher.final()]); return plaintext.toString("utf8"); -} \ No newline at end of file +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9cd8c01..8b3b01f 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -4,7 +4,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; export { encryptSecret, decryptSecret } from "./crypto.js"; -export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, exists, gte, gt, ilike, inArray, isNull, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null;