Skip to content

Commit 4dd9f98

Browse files
authored
feat(workflow): inject per-tenant secrets as env vars at spawn time (#228)
Adds a `tenant_secrets_json` input (workflow_dispatch) and a `tenant_secrets` field on `client_payload` (repository_dispatch) so the calling app can ship per-tenant bearers / endpoints / admin keys into the spawning workflow without permanently storing them as repo secrets. Flow: app → workflow trigger (with tenant_secrets) → Inject step parses, ::add-mask::'s each value, writes to GITHUB_ENV via heredoc with a per-value random delimiter → Run lifecycle step calls the CLI → CLI's ${VAR} substitution resolves them into the tenant config sent to Basilica. Two trigger paths exposed: - workflow_dispatch.tenant_secrets_json — JSON string; values masked in logs but the input itself is visible in the run UI to actions:read. Fine for testing and trusted-tenant tiers. - repository_dispatch event_type=tenant-lifecycle with client_payload.tenant_secrets — payload NOT visible in run UI. The production path; documented inline. Also normalises both trigger paths into shared job-level env vars (TENANT_ID, ACTION, CFG_PATH, CFG_JSON, PROXY_ID, DASHBOARD_ID, STRATEGY) via a single Resolve-Inputs Python step, so the downstream steps don't have to branch on event type. Validated locally: - workflow_dispatch with tenant_secrets_json correctly populates GITHUB_ENV and secrets.json staging file - repository_dispatch with client_payload.tenant_secrets does the same - Multi-line PEM values, empty strings, and special chars ($, ", #, :, =) all round-trip through the heredoc form - Random per-value delimiter (secrets.token_hex(8)) eliminates EOF collision risk No code changes to lifecycle.py / cli.py — they already supported the ${VAR} substitution shape the workflow now feeds them.
1 parent 0823ec8 commit 4dd9f98

1 file changed

Lines changed: 138 additions & 33 deletions

File tree

.github/workflows/tenant-lifecycle.yml

Lines changed: 138 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ on:
4444
options:
4545
- recreate
4646
- restart
47+
tenant_secrets_json:
48+
description: >-
49+
JSON object of per-tenant secrets to inject as env vars before the
50+
CLI runs, e.g. {"OPENAI_API_KEY":"sk-...","LLMTRACE_UPSTREAM_URL":"https://..."}.
51+
The CLI's ${VAR} substitution resolves these into the tenant config.
52+
Values are masked in step logs via ::add-mask::, but workflow_dispatch
53+
input VALUES are visible in the run UI to anyone with actions:read.
54+
For production multi-tenant use, trigger via repository_dispatch with
55+
client_payload (event_type: tenant-lifecycle) — those payloads are
56+
NOT shown in the run UI. See README for the migration path.
57+
required: false
58+
type: string
59+
default: "{}"
60+
61+
# Production trigger: client_payload is not visible in the run UI.
62+
# POST /repos/{owner}/{repo}/dispatches with:
63+
# {"event_type": "tenant-lifecycle",
64+
# "client_payload": {"tenant_id": "...", "action": "provision",
65+
# "config_json": "{...}",
66+
# "tenant_secrets": {"OPENAI_API_KEY": "sk-..."}}}
67+
repository_dispatch:
68+
types:
69+
- tenant-lifecycle
4770

4871
permissions:
4972
contents: read
@@ -73,76 +96,161 @@ jobs:
7396
python3 -m pip install --upgrade pip
7497
python3 -m pip install basilica-sdk PyYAML
7598
76-
- name: Validate inputs
99+
- name: Resolve inputs (workflow_dispatch or repository_dispatch)
77100
env:
78-
ACTION: ${{ inputs.action }}
79-
CONFIG_PATH: ${{ inputs.config_path }}
80-
CONFIG_JSON: ${{ inputs.config_json }}
81-
PROXY_ID: ${{ inputs.proxy_instance_id }}
82-
DASHBOARD_ID: ${{ inputs.dashboard_instance_id }}
101+
EVENT_NAME: ${{ github.event_name }}
102+
WF_TENANT_ID: ${{ inputs.tenant_id }}
103+
WF_ACTION: ${{ inputs.action }}
104+
WF_CONFIG_PATH: ${{ inputs.config_path }}
105+
WF_CONFIG_JSON: ${{ inputs.config_json }}
106+
WF_PROXY_ID: ${{ inputs.proxy_instance_id }}
107+
WF_DASHBOARD_ID: ${{ inputs.dashboard_instance_id }}
108+
WF_STRATEGY: ${{ inputs.strategy }}
109+
WF_SECRETS: ${{ inputs.tenant_secrets_json }}
110+
RD_PAYLOAD: ${{ toJson(github.event.client_payload) }}
111+
run: |
112+
set -euo pipefail
113+
python3 - <<'PY'
114+
import json, os, pathlib
115+
event = os.environ.get("EVENT_NAME", "")
116+
if event == "workflow_dispatch":
117+
tenant_id = os.environ.get("WF_TENANT_ID", "")
118+
action = os.environ.get("WF_ACTION", "")
119+
cfg_path = os.environ.get("WF_CONFIG_PATH", "")
120+
cfg_json = os.environ.get("WF_CONFIG_JSON", "")
121+
proxy_id = os.environ.get("WF_PROXY_ID", "")
122+
dash_id = os.environ.get("WF_DASHBOARD_ID", "")
123+
strategy = os.environ.get("WF_STRATEGY", "")
124+
secrets_json = os.environ.get("WF_SECRETS", "{}") or "{}"
125+
try:
126+
secrets = json.loads(secrets_json)
127+
except json.JSONDecodeError as exc:
128+
raise SystemExit(f"::error::tenant_secrets_json is invalid JSON: {exc}")
129+
elif event == "repository_dispatch":
130+
payload = json.loads(os.environ.get("RD_PAYLOAD", "{}") or "{}")
131+
tenant_id = payload.get("tenant_id", "")
132+
action = payload.get("action", "")
133+
cfg_path = payload.get("config_path", "")
134+
cfg_json = payload.get("config_json", "")
135+
if cfg_json and not isinstance(cfg_json, str):
136+
cfg_json = json.dumps(cfg_json)
137+
proxy_id = payload.get("proxy_instance_id", "")
138+
dash_id = payload.get("dashboard_instance_id", "")
139+
strategy = payload.get("strategy", "recreate")
140+
secrets = payload.get("tenant_secrets", {}) or {}
141+
else:
142+
raise SystemExit(f"::error::unsupported event {event!r}")
143+
144+
if not isinstance(secrets, dict):
145+
raise SystemExit("::error::tenant_secrets must be a JSON object")
146+
147+
env_path = pathlib.Path(os.environ["GITHUB_ENV"])
148+
with env_path.open("a") as fh:
149+
fh.write(f"TENANT_ID={tenant_id}\n")
150+
fh.write(f"ACTION={action}\n")
151+
fh.write(f"CFG_PATH={cfg_path}\n")
152+
fh.write(f"PROXY_ID={proxy_id}\n")
153+
fh.write(f"DASHBOARD_ID={dash_id}\n")
154+
fh.write(f"STRATEGY={strategy}\n")
155+
# config_json may contain newlines; use heredoc form
156+
if cfg_json:
157+
fh.write(f"CFG_JSON<<__LIFECYCLE_EOF__\n{cfg_json}\n__LIFECYCLE_EOF__\n")
158+
159+
pathlib.Path("/tmp/tenant_secrets.json").write_text(json.dumps(secrets))
160+
print(f"resolved: tenant_id={tenant_id} action={action} secret_keys={sorted(secrets.keys())}")
161+
PY
162+
163+
- name: Inject tenant secrets
164+
env:
165+
SECRETS_PATH: /tmp/tenant_secrets.json
166+
run: |
167+
set -euo pipefail
168+
python3 - <<'PY'
169+
import json, os, pathlib, secrets as _secrets
170+
data = json.loads(pathlib.Path(os.environ["SECRETS_PATH"]).read_text() or "{}")
171+
if not data:
172+
print("no tenant secrets to inject")
173+
raise SystemExit(0)
174+
env_path = pathlib.Path(os.environ["GITHUB_ENV"])
175+
with env_path.open("a") as fh:
176+
for key, value in data.items():
177+
if not isinstance(key, str) or not key:
178+
raise SystemExit(f"::error::secret key must be a non-empty string, got {key!r}")
179+
value = "" if value is None else str(value)
180+
# Mask FIRST so any later log line containing the value is redacted.
181+
print(f"::add-mask::{value}")
182+
# Random per-value delimiter — paranoid guard against a secret
183+
# that happens to contain a static EOF token.
184+
delim = f"__SEC_{_secrets.token_hex(8)}__"
185+
if delim in value:
186+
raise SystemExit(f"::error::generated delimiter collided with secret value for {key}")
187+
fh.write(f"{key}<<{delim}\n{value}\n{delim}\n")
188+
print(f"injected {key} ({len(value)} chars)")
189+
PY
190+
191+
- name: Validate inputs
83192
run: |
84193
set -euo pipefail
85194
if [[ -z "${BASILICA_API_TOKEN:-}" ]]; then
86195
echo "::error::BASILICA_API_TOKEN secret is not configured"
87196
exit 2
88197
fi
89-
case "${ACTION}" in
198+
if [[ -z "${TENANT_ID:-}" ]]; then
199+
echo "::error::tenant_id is required"
200+
exit 2
201+
fi
202+
case "${ACTION:-}" in
90203
provision)
91-
if [[ -z "${CONFIG_PATH}" && -z "${CONFIG_JSON}" ]]; then
204+
if [[ -z "${CFG_PATH:-}" && -z "${CFG_JSON:-}" ]]; then
92205
echo "::error::action=provision requires config_path or config_json"
93206
exit 2
94207
fi
95208
;;
96209
update)
97-
if [[ -z "${CONFIG_PATH}" && -z "${CONFIG_JSON}" ]]; then
210+
if [[ -z "${CFG_PATH:-}" && -z "${CFG_JSON:-}" ]]; then
98211
echo "::error::action=update requires config_path or config_json"
99212
exit 2
100213
fi
101-
if [[ -z "${PROXY_ID}" || -z "${DASHBOARD_ID}" ]]; then
214+
if [[ -z "${PROXY_ID:-}" || -z "${DASHBOARD_ID:-}" ]]; then
102215
echo "::error::action=update requires proxy_instance_id and dashboard_instance_id"
103216
exit 2
104217
fi
105218
;;
219+
status|deprovision)
220+
:
221+
;;
222+
*)
223+
echo "::error::unknown or missing action: ${ACTION:-<empty>}"
224+
exit 2
225+
;;
106226
esac
107227
108228
- name: Run lifecycle action
109229
id: run
110-
env:
111-
TENANT_ID: ${{ inputs.tenant_id }}
112-
ACTION: ${{ inputs.action }}
113-
CONFIG_PATH: ${{ inputs.config_path }}
114-
CONFIG_JSON: ${{ inputs.config_json }}
115-
PROXY_ID: ${{ inputs.proxy_instance_id }}
116-
DASHBOARD_ID: ${{ inputs.dashboard_instance_id }}
117-
STRATEGY: ${{ inputs.strategy }}
118230
run: |
119231
set -euo pipefail
120232
args=("--tenant-id" "${TENANT_ID}")
121233
case "${ACTION}" in
122234
provision)
123-
if [[ -n "${CONFIG_JSON}" ]]; then
124-
args+=("--config-json" "${CONFIG_JSON}")
235+
if [[ -n "${CFG_JSON:-}" ]]; then
236+
args+=("--config-json" "${CFG_JSON}")
125237
else
126-
args+=("--config" "${CONFIG_PATH}")
238+
args+=("--config" "${CFG_PATH}")
127239
fi
128240
;;
129241
update)
130242
args+=("--strategy" "${STRATEGY}")
131243
args+=("--proxy-instance-id" "${PROXY_ID}")
132244
args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
133-
if [[ -n "${CONFIG_JSON}" ]]; then
134-
args+=("--config-json" "${CONFIG_JSON}")
245+
if [[ -n "${CFG_JSON:-}" ]]; then
246+
args+=("--config-json" "${CFG_JSON}")
135247
else
136-
args+=("--config" "${CONFIG_PATH}")
248+
args+=("--config" "${CFG_PATH}")
137249
fi
138250
;;
139251
status|deprovision)
140-
[[ -n "${PROXY_ID}" ]] && args+=("--proxy-instance-id" "${PROXY_ID}")
141-
[[ -n "${DASHBOARD_ID}" ]] && args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
142-
;;
143-
*)
144-
echo "::error::unknown action ${ACTION}"
145-
exit 2
252+
[[ -n "${PROXY_ID:-}" ]] && args+=("--proxy-instance-id" "${PROXY_ID}")
253+
[[ -n "${DASHBOARD_ID:-}" ]] && args+=("--dashboard-instance-id" "${DASHBOARD_ID}")
146254
;;
147255
esac
148256
@@ -166,9 +274,6 @@ jobs:
166274
167275
- name: Write step summary
168276
if: always()
169-
env:
170-
ACTION: ${{ inputs.action }}
171-
TENANT_ID: ${{ inputs.tenant_id }}
172277
run: |
173278
set -euo pipefail
174279
{

0 commit comments

Comments
 (0)