diff --git a/deploy/k8s/keycloak/keycloak-bootstrap-job.yaml b/deploy/k8s/keycloak/keycloak-bootstrap-job.yaml new file mode 100644 index 00000000..f8473960 --- /dev/null +++ b/deploy/k8s/keycloak/keycloak-bootstrap-job.yaml @@ -0,0 +1,75 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: keycloak-bootstrap-realm + namespace: keycloak + labels: + app.kubernetes.io/name: keycloak-bootstrap-realm + hwlab.pikastech.local/component: identity +spec: + backoffLimit: 5 + template: + metadata: + labels: + app.kubernetes.io/name: keycloak-bootstrap-realm + hwlab.pikastech.local/component: identity + spec: + restartPolicy: OnFailure + containers: + - name: bootstrap + image: curlimages/curl:8.10.1 + command: + - /bin/sh + - -ceu + - | + set -eu + KC=http://keycloak.keycloak.svc.cluster.local:8080 + until curl -sf $KC/health/ready > /dev/null; do + echo waiting-for-keycloak; sleep 5 + done + ADMIN_USER=admin + ADMIN_PASSWORD=$(cat /tmp/admin-password) + CLIENT_ID=hwlab-cloud-web + CLIENT_SECRET=$(cat /tmp/client-secret) + REALM=hwlab + until TOKEN=$(curl -sf -X POST "$KC/realms/master/protocol/openid-connect/token" -H "content-type: application/x-www-form-urlencoded" -d "username=$ADMIN_USER&password=$ADMIN_PASSWORD&grant_type=password&client_id=admin-cli" | sed -n '"'"'s/.*"access_token":"\([^"]*\)".*/\1/p'"'"'); do + echo waiting-for-admin-cli; sleep 5 + done + if curl -sf -H "Authorization: Bearer $TOKEN" $KC/admin/realms/$REALM > /dev/null; then + echo realm-already-exists; exit 0 + fi + curl -sf -X POST $KC/admin/realms -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" -d "{\"realm\":\"$REALM\",\"enabled\":true,\"registrationAllowed\":true,\"verifyEmail\":false,\"loginWithEmailAllowed\":true,\"duplicateEmailsAllowed\":false,\"resetPasswordAllowed\":false,\"editUsernameAllowed\":false,\"bruteForceProtected\":false,\"accessTokenLifespan\":1440,\"ssoSessionIdleTimeout\":86400,\"ssoSessionMaxLifespan\":86400}" + echo realm-created + curl -sf -X POST $KC/admin/realms/$REALM/clients -H "Authorization: Bearer $TOKEN" -H "content-type: application/json" -d "{\"clientId\":\"$CLIENT_ID\",\"secret\":\"$CLIENT_SECRET\",\"redirectUris\":[\"http://74.48.78.17:19667/auth/oidc/callback\",\"https://auth.74-48-78-17.nip.io/auth/oidc/callback\"],\"webOrigins\":[\"+\"],\"standardFlowEnabled\":true,\"directAccessGrantsEnabled\":true}" + echo client-created + env: + - name: KC_BOOTSTRAP_ADMIN_USERNAME + value: admin + volumeMounts: + - name: admin-password + mountPath: /tmp/admin-password + subPath: password + readOnly: true + - name: client-secret + mountPath: /tmp/client-secret + subPath: client-secret + readOnly: true + volumes: + - name: admin-password + secret: + secretName: keycloak-admin + - name: client-secret + secret: + secretName: hwlab-cloud-web-client +--- +apiVersion: v1 +kind: Secret +metadata: + name: hwlab-cloud-web-client + namespace: keycloak + labels: + app.kubernetes.io/name: hwlab-cloud-web-client + hwlab.pikastech.local/component: identity +type: Opaque +stringData: + client-secret: kc-client-secret-placeholder diff --git a/deploy/k8s/keycloak/kustomization.yaml b/deploy/k8s/keycloak/kustomization.yaml index d98521d2..49a98192 100644 --- a/deploy/k8s/keycloak/kustomization.yaml +++ b/deploy/k8s/keycloak/kustomization.yaml @@ -5,6 +5,7 @@ resources: - namespace.yaml - postgres.yaml - keycloak.yaml + - keycloak-bootstrap-job.yaml commonLabels: app.kubernetes.io/part-of: hwlab hwlab.pikastech.local/profile: dev diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index bb958aa2..30907f4c 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -2721,3 +2721,57 @@ test("cloud api issues a 24h Web session with Secure/SameSite cookie and exposes await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); } }); + +test("cloud api /v1/users/me returns actor with authMethod=api-key for HWLAB_API_KEY Bearer token", async () => { + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + now: () => "2026-06-03T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const setup = await postJson(port, "/v1/setup/first-admin", { username: "actor-admin", password: "actor-pass" }); + const cookie = setup.cookie; + const apiKey = (await getJson(port, "/v1/api-keys/default", cookie)).body.key.displaySecret; + const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); + assert.equal(me.status, 200); + assert.equal(me.body.authMethod, "api-key"); + assert.equal(me.body.actor.username, "actor-admin"); + assert.equal(me.body.actor.role, "admin"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + +test("cloud api AgentRun trace records real actor.userId from OIDC-upserted user (issue 788 spec)", async () => { + const accessController = createAccessController({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_KEYCLOAK_ISSUER: "https://auth.74-48-78-17.nip.io/realms/hwlab", HWLAB_KEYCLOAK_CLIENT_ID: "hwlab-cloud-web", HWLAB_KEYCLOAK_CLIENT_SECRET: "secret" }, + fetchImpl: makeOidcFetchMock(), + now: () => "2026-06-03T00:00:00.000Z" + }); + const state = "actor-state"; + await accessController.store.upsertOidcState({ state, nonce: "actor-nonce", returnTo: "/", createdAt: "2026-06-03T00:00:00.000Z", expiresAt: "2026-06-03T00:10:00.000Z" }); + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + accessController, + now: () => "2026-06-03T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/auth/oidc/callback?code=oidc-code&state=${state}`, { redirect: "manual" }); + const cookie = (response.headers.get("set-cookie") ?? "").split(";")[0]; + const session = await getJson(port, "/v1/auth/session", cookie); + const actor = session.body.actor; + assert.equal(actor.username, "oidc-user"); + assert.equal(actor.role, "user"); + const apiKeys = await getJson(port, "/v1/api-keys", cookie); + assert.equal(apiKeys.body.keys.length, 1); + const apiKey = apiKeys.body.keys[0].displaySecret; + const me = await getJson(port, "/v1/users/me", null, { authorization: `Bearer ${apiKey}` }); + assert.equal(me.body.actor.id, actor.id); + assert.equal(me.body.authMethod, "api-key"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +});