Author SHA1 Message Date
sharang f2f9ab74c8 RBAC: send a client_credentials service token to tenant-registry (#21)
ci / shared (push) Failing after 12s
ci / test (push) Failing after 5m3s
ci / e2e (push) Skipped
ci / image (push) Skipped
2026-08-25 09:15:47 +00:00
sharang 7e62c0162f Repoint registry.breakpilot.com -> repo.breakpilot.com (Harbor) (#20)
ci / shared (push) Failing after 11s
ci / test (push) Successful in 10m16s
ci / image (push) Skipped
ci / e2e (push) Canceled after 0s
2026-08-09 21:22:10 +00:00
sharang 329cc0e57a chore(ci): repoint registry/git/cargo meghsakha.com -> breakpilot.com (#19)
ci / test (push) Successful in 10m16s
ci / image (push) Skipped
ci / e2e (push) Canceled after 0s
ci / shared (push) Failing after 12s
2026-08-06 09:49:52 +00:00
sharang da6b8f70c5 Merge pull request 'ci: fix cosign signing (install fallback, env-style login, portal sign step)' (#18) from ci/cosign-fixes into main
ci / shared (push) Failing after 12s
ci / test (push) Successful in 10m14s
ci / image (push) Skipped
ci / e2e (push) Canceled after 0s
2026-07-22 07:46:02 +00:00
sharang a3c1bb075c ci: fix cosign install (curl->wget fallback), env-style registry login, portal sign step
ci / test (pull_request) Successful in 10m20s
ci / shared (pull_request) Failing after 5s
ci / image (pull_request) Skipped
ci / e2e (pull_request) Canceled after 0s
2026-07-22 07:44:16 +00:00
sharang af13f054e4 Merge pull request 'ci: push images to Harbor (repo.meghsakha.com) + cosign' (#16) from ci/harbor-repo-meghsakha into main
ci / e2e (push) Canceled after 0s
ci / shared (push) Successful in 12s
ci / test (push) Successful in 10m17s
ci / image (push) Canceled after 0s
2026-07-21 12:14:29 +00:00
sharang 0a4af3b758 ci: push to Harbor (repo.meghsakha.com) + cosign signing
ci / shared (pull_request) Successful in 23s
ci / e2e (pull_request) Canceled after 0s
ci / test (pull_request) Successful in 10m22s
ci / image (pull_request) Skipped
2026-07-21 10:56:36 +00:00
sharang 5856c1c732 feat(portal): allow PORTAL_APEX_HOSTS env to extend APEX_HOSTS (#15)
ci / shared (push) Successful in 12s
ci / test (push) Successful in 10m17s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
2026-06-10 12:05:51 +00:00
sharang 0862420e7c ci(portal): retarget image build to registry.meghsakha.com + orca webhook (#14)
ci / shared (push) Successful in 14s
ci / test (push) Successful in 10m18s
ci / e2e (push) Has been skipped
ci / image (push) Has been skipped
2026-06-10 12:05:37 +00:00
6 changed files with 278 additions and 10 deletions
+13 -4
View File
@@ -8,6 +8,10 @@ on:
push:
branches: [main]
env:
COSIGN_KEY: ${{ secrets.COSIGN_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
jobs:
shared:
runs-on: docker
@@ -110,7 +114,7 @@ jobs:
image:
# Builds the portal image and ships it through the same path every
# other service in orca-infra uses: push :latest + :sha-<sha> to
# registry.meghsakha.com, then POST a github-style payload to the
# repo.breakpilot.com, then POST a github-style payload to the
# orca webhook so the master pulls and redeploys breakpilot-portal.
#
# Webhook target (registered once on the master via
@@ -124,15 +128,20 @@ jobs:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: registry.meghsakha.com
registry: repo.breakpilot.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- uses: docker/build-push-action@v6
with:
push: true
tags: |
registry.meghsakha.com/breakpilot/portal:latest
registry.meghsakha.com/breakpilot/portal:sha-${{ github.sha }}
repo.breakpilot.com/breakpilot/portal:latest
repo.breakpilot.com/breakpilot/portal:sha-${{ github.sha }}
- name: sign image (cosign)
run: |
{ command -v cosign >/dev/null 2>&1 || curl -sSfLo /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64 || wget -qO /usr/local/bin/cosign https://github.com/sigstore/cosign/releases/download/v2.4.3/cosign-linux-amd64; } || echo "::warning::cosign fetch failed"
chmod +x /usr/local/bin/cosign 2>/dev/null || true
cosign sign --yes --key env://COSIGN_KEY repo.breakpilot.com/breakpilot/portal:latest || echo "::warning::cosign failed"
- name: trigger orca redeploy
# Signs the POST with HMAC-SHA256 over the JSON body using the
# secret orca generated when the webhook was registered. Orca's
+4 -4
View File
@@ -22,7 +22,7 @@ jobs:
- name: verify stage soak (>= 24h on this image)
run: |
IMG=registry.breakpilot.com/${{ github.event.repository.name }}:env-stage
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}:env-stage
SOAK_SECONDS=$(orca image-age --env=stage --image $IMG)
if [ "$SOAK_SECONDS" -lt 86400 ]; then
echo "Stage soak only $SOAK_SECONDS s, < 24h. Aborting."
@@ -34,12 +34,12 @@ jobs:
- name: re-tag image as semver + env-prod
uses: docker/login-action@v3
with:
registry: registry.breakpilot.com
registry: repo.breakpilot.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- run: |
IMG=registry.breakpilot.com/${{ github.event.repository.name }}
IMG=repo.breakpilot.com/breakpilot/${{ github.event.repository.name }}
docker pull $IMG:env-stage
docker tag $IMG:env-stage $IMG:v${{ steps.v.outputs.version }}
docker tag $IMG:env-stage $IMG:env-prod
@@ -67,7 +67,7 @@ jobs:
curl -X POST -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$(jq -Rs '{tag_name:"v${{ steps.v.outputs.version }}", name:"v${{ steps.v.outputs.version }}", body:.}' < RELEASE_NOTES.md)" \
https://gitea.meghsakha.com/api/v1/repos/${{ github.repository }}/releases
https://git.breakpilot.com/api/v1/repos/${{ github.repository }}/releases
rollback-on-failure:
needs: promote
+14 -1
View File
@@ -11,7 +11,20 @@ export type HostMatch =
| { kind: "unknown" };
// Longest-first so `stage.breakpilot.com` is matched before `breakpilot.com`.
const APEX_HOSTS = ["stage.breakpilot.com", "breakpilot.com", "localhost"];
// Built-ins cover dev (localhost) + the canonical breakpilot.com targets.
// PORTAL_APEX_HOSTS is a comma-separated env override for per-environment
// hosts (e.g. portal-dev.meghsakha.com while breakpilot.com isn't registered).
const APEX_HOSTS = (() => {
const base = ["stage.breakpilot.com", "breakpilot.com", "localhost"];
const extra = (process.env.PORTAL_APEX_HOSTS ?? "")
.split(",")
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
// Longest-first to keep the suffix-strip loop correct.
return Array.from(new Set([...extra, ...base])).sort(
(a, b) => b.length - a.length,
);
})();
const APEX_SET = new Set(APEX_HOSTS);
export function parseHost(host: string | null | undefined): HostMatch {
+145
View File
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
resetServiceTokenCache,
serviceAuthHeader,
serviceToken,
} from "./service-token";
const ISSUER = "https://auth.breakpilot.com/realms/breakpilot-dev";
function tokenResponse(value: string, expiresIn = 300) {
return {
ok: true,
status: 200,
json: async () => ({ access_token: value, expires_in: expiresIn }),
} as Response;
}
function configure() {
process.env.KEYCLOAK_ISSUER = ISSUER;
process.env.PORTAL_SVC_CLIENT_ID = "portal-svc";
process.env.PORTAL_SVC_CLIENT_SECRET = "shh";
}
beforeEach(() => {
resetServiceTokenCache();
delete process.env.PORTAL_SVC_CLIENT_ID;
delete process.env.PORTAL_SVC_CLIENT_SECRET;
delete process.env.KEYCLOAK_ISSUER;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("serviceToken", () => {
test("returns null and makes no request when unconfigured", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
expect(await serviceToken()).toBeNull();
expect(await serviceAuthHeader()).toEqual({});
expect(fetchSpy).not.toHaveBeenCalled();
});
test("requests a client_credentials token against the realm", async () => {
configure();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(tokenResponse("tok-1"));
expect(await serviceToken()).toBe("tok-1");
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${ISSUER}/protocol/openid-connect/token`);
expect(init.method).toBe("POST");
const body = new URLSearchParams(init.body as string);
expect(body.get("grant_type")).toBe("client_credentials");
expect(body.get("client_id")).toBe("portal-svc");
expect(body.get("client_secret")).toBe("shh");
});
test("caches the token across calls", async () => {
configure();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(tokenResponse("tok-1"));
await serviceToken();
await serviceToken();
await serviceToken();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
test("de-dupes concurrent fetches into one request", async () => {
configure();
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(tokenResponse("tok-1"));
const results = await Promise.all([
serviceToken(),
serviceToken(),
serviceToken(),
]);
expect(results).toEqual(["tok-1", "tok-1", "tok-1"]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
test("refreshes shortly before expiry", async () => {
configure();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-25T10:00:00Z"));
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(tokenResponse("tok-1", 300))
.mockResolvedValueOnce(tokenResponse("tok-2", 300));
expect(await serviceToken()).toBe("tok-1");
// 4 minutes in: still inside the window
vi.setSystemTime(new Date("2026-08-25T10:04:00Z"));
expect(await serviceToken()).toBe("tok-1");
// 4:40 — inside the 30s refresh margin
vi.setSystemTime(new Date("2026-08-25T10:04:40Z"));
expect(await serviceToken()).toBe("tok-2");
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
test("surfaces a rejected token request instead of calling unauthenticated", async () => {
configure();
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
} as Response);
await expect(serviceToken()).rejects.toThrow("service token request failed: 401");
});
test("surfaces a malformed token response", async () => {
configure();
vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
status: 200,
json: async () => ({}),
} as Response);
await expect(serviceToken()).rejects.toThrow("no access_token");
});
test("a failed fetch does not poison the cache", async () => {
configure();
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) } as Response)
.mockResolvedValueOnce(tokenResponse("tok-ok"));
await expect(serviceToken()).rejects.toThrow();
expect(await serviceToken()).toBe("tok-ok");
});
test("serviceAuthHeader carries the bearer token when configured", async () => {
configure();
vi.spyOn(globalThis, "fetch").mockResolvedValue(tokenResponse("tok-1"));
expect(await serviceAuthHeader()).toEqual({ authorization: "Bearer tok-1" });
});
});
+96
View File
@@ -0,0 +1,96 @@
// Client-credentials service token for portal → tenant-registry calls.
//
// tenant-registry's API is INTERNAL_SERVICE_ONLY: once its AUTH_ENABLED
// flips, every route except /healthz and /readyz needs a Keycloak token
// whose audience contains `tenant-registry`. The portal is a service
// principal here — this is machine-to-machine, unrelated to the visitor's
// SSO session (that one authenticates a human against `dev-portal`).
//
// Inert until configured: with no PORTAL_SVC_CLIENT_ID / _SECRET the
// helper returns null and callers send no Authorization header, which is
// exactly today's behaviour against a tenant-registry that is not yet
// enforcing. Configure both to switch the portal over.
//
// Server-only: the client secret must never reach the browser. Every
// caller (src/lib/tenant-registry.ts) already runs server-side.
const REFRESH_MARGIN_SECONDS = 30;
type CachedToken = { value: string; expiresAt: number };
let cached: CachedToken | null = null;
// de-dupes concurrent fetches: many parallel renders share one request
let inFlight: Promise<CachedToken> | null = null;
function config(): { issuer: string; clientId: string; secret: string } | null {
const clientId = process.env.PORTAL_SVC_CLIENT_ID;
const secret = process.env.PORTAL_SVC_CLIENT_SECRET;
const issuer = process.env.KEYCLOAK_ISSUER;
if (!clientId || !secret || !issuer) return null;
return { issuer, clientId, secret };
}
async function fetchToken(cfg: {
issuer: string;
clientId: string;
secret: string;
}): Promise<CachedToken> {
const res = await fetch(`${cfg.issuer}/protocol/openid-connect/token`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: cfg.clientId,
client_secret: cfg.secret,
}),
cache: "no-store",
});
if (!res.ok) {
throw new Error(`service token request failed: ${res.status}`);
}
const body = (await res.json()) as {
access_token?: string;
expires_in?: number;
};
if (!body.access_token) {
throw new Error("service token response carried no access_token");
}
return {
value: body.access_token,
expiresAt: Date.now() / 1000 + (body.expires_in ?? 300),
};
}
/**
* A valid service token, or null when the portal is not configured to
* send one. Cached in memory and refreshed shortly before expiry (realm
* tokens live 5 minutes).
*/
export async function serviceToken(): Promise<string | null> {
const cfg = config();
if (!cfg) return null;
const now = Date.now() / 1000;
if (cached && now < cached.expiresAt - REFRESH_MARGIN_SECONDS) {
return cached.value;
}
if (!inFlight) {
inFlight = fetchToken(cfg).finally(() => {
inFlight = null;
});
}
cached = await inFlight;
return cached.value;
}
/** Authorization header for an outbound call, or {} when unconfigured. */
export async function serviceAuthHeader(): Promise<Record<string, string>> {
const token = await serviceToken();
return token ? { authorization: `Bearer ${token}` } : {};
}
/** Test seam: drop the cached token. */
export function resetServiceTokenCache(): void {
cached = null;
inFlight = null;
}
+6 -1
View File
@@ -1,6 +1,8 @@
// Tenant Registry client — covers everything the portal needs to call
// from server components and server actions.
import { serviceAuthHeader } from "./service-token";
export type Tenant = {
id: string;
slug: string;
@@ -74,9 +76,12 @@ async function req<T>(
path: string,
body?: unknown,
): Promise<{ status: number; data: T | null }> {
// tenant-registry is INTERNAL_SERVICE_ONLY — the portal calls it as a
// service principal. Unconfigured ⇒ {} ⇒ unchanged, header-less calls.
const auth = await serviceAuthHeader();
const init: RequestInit = {
method,
headers: { accept: "application/json" },
headers: { accept: "application/json", ...auth },
cache: "no-store",
};
if (body !== undefined) {