Skip to content

Commit 7c809df

Browse files
authored
Merge pull request #691 from rajbos/rajbos/sharing-server-admin-github-logins
feat(sharing-server): add ADMIN_GITHUB_LOGINS for declarative admin management
2 parents 338adab + fe76622 commit 7c809df

7 files changed

Lines changed: 161 additions & 35 deletions

File tree

.github/workflows/sharing-server-deploy.yml

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ on:
1111
- ".github/workflows/sharing-server-deploy.yml"
1212
workflow_dispatch:
1313

14+
# One deploy per branch at a time — prevents concurrent Terraform runs from
15+
# conflicting on the same remote state file (state blob already locked errors).
16+
# Cancel in-progress for branch pushes so stale deploys don't block newer ones;
17+
# queue (no cancel) for main so production deploys always complete.
18+
concurrency:
19+
group: sharing-server-deploy-${{ github.ref }}
20+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
21+
1422
# Minimal baseline; jobs declare only what they need.
1523
permissions:
1624
contents: read
@@ -179,6 +187,7 @@ jobs:
179187
TF_VAR_session_secret: ${{ secrets.SHARING_SESSION_SECRET }}
180188
TF_VAR_allowed_github_org: ${{ vars.SHARING_ALLOWED_GITHUB_ORG }}
181189
TF_VAR_github_org_check_token: ${{ secrets.ORG_CHECK_TOKEN }}
190+
TF_VAR_admin_github_logins: ${{ vars.SHARING_ADMIN_GITHUB_LOGINS }}
182191
TF_VAR_min_replicas: ${{ needs.setup.outputs.min_replicas }}
183192
TF_VAR_custom_domain: ${{ vars.SHARING_CUSTOM_DOMAIN }}
184193
run: |
@@ -196,35 +205,71 @@ jobs:
196205
| grep -E '^\s+name\s+=' | head -1 \
197206
| sed 's/.*= "\(.*\)".*/\1/' || true)
198207
199-
if [[ "$CURRENT_CERT_NAME" != "$EXPECTED_CERT_NAME" ]]; then
200-
echo "Cert not TF-managed (current: '${CURRENT_CERT_NAME:-none}'). Cleaning up Azure resources so Terraform can recreate them."
201-
202-
# Remove hostname binding first (cert cannot be deleted while a domain uses it)
203-
az containerapp hostname delete \
204-
--name "$TF_VAR_app_name" \
205-
--resource-group "$TF_VAR_resource_group_name" \
206-
--hostname "$TF_VAR_custom_domain" --yes 2>/dev/null || true
207-
208-
# Find the cert by subject name and delete it
209-
AZURE_CERT_NAME=$(az containerapp env certificate list \
208+
if [[ "$CURRENT_CERT_NAME" == "$EXPECTED_CERT_NAME" ]]; then
209+
echo "Cert already TF-managed as '$EXPECTED_CERT_NAME'. No cleanup needed."
210+
else
211+
# Cert is absent from TF state or has a mismatched name.
212+
# Before deleting anything, check whether the correctly-named cert already
213+
# exists in Azure (e.g. a previous apply timed out while polling for the cert
214+
# to become Succeeded, leaving it stranded in Azure but dropped from TF state).
215+
AZURE_CERT_ID=$(az containerapp env certificate list \
210216
--name "$ENV_NAME" \
211217
--resource-group "$TF_VAR_resource_group_name" \
212-
--query "[?properties.subjectName=='$TF_VAR_custom_domain'].name | [0]" \
218+
--query "[?name=='$EXPECTED_CERT_NAME'].id | [0]" \
213219
-o tsv 2>/dev/null || true)
214-
if [[ -n "$AZURE_CERT_NAME" && "$AZURE_CERT_NAME" != "None" ]]; then
215-
echo "Deleting Azure cert: $AZURE_CERT_NAME"
216-
az containerapp env certificate delete \
220+
221+
if [[ -n "$AZURE_CERT_ID" && "$AZURE_CERT_ID" != "None" ]]; then
222+
# The correctly-named cert exists in Azure but TF lost track of it.
223+
# Import it so apply doesn't delete-and-recreate (which resets provisioning
224+
# and triggers another 60-minute wait).
225+
echo "Cert '$EXPECTED_CERT_NAME' found in Azure but not in TF state. Importing..."
226+
if terraform import 'azurerm_container_app_environment_managed_certificate.this[0]' "$AZURE_CERT_ID"; then
227+
# Drop stale custom-domain state so cert_binding re-runs to re-bind.
228+
terraform state rm 'azurerm_container_app_custom_domain.this[0]' 2>/dev/null || true
229+
echo "Import done. Terraform will rebind the cert without recreating it."
230+
else
231+
# Import failed; delete the Azure cert so apply doesn't hit "already exists".
232+
echo "Import failed. Deleting Azure cert so Terraform can create a fresh one."
233+
az containerapp hostname delete \
234+
--name "$TF_VAR_app_name" \
235+
--resource-group "$TF_VAR_resource_group_name" \
236+
--hostname "$TF_VAR_custom_domain" --yes 2>/dev/null || true
237+
az containerapp env certificate delete \
238+
--name "$ENV_NAME" \
239+
--resource-group "$TF_VAR_resource_group_name" \
240+
--certificate "$EXPECTED_CERT_NAME" --yes 2>/dev/null || true
241+
terraform state rm 'azurerm_container_app_custom_domain.this[0]' 2>/dev/null || true
242+
terraform state rm 'azurerm_container_app_environment_managed_certificate.this[0]' 2>/dev/null || true
243+
echo "Cleanup done. Terraform will create cert and domain binding from scratch."
244+
fi
245+
else
246+
echo "Cert not TF-managed (current: '${CURRENT_CERT_NAME:-none}'). Cleaning up Azure resources so Terraform can recreate them."
247+
248+
# Remove hostname binding first (cert cannot be deleted while a domain uses it)
249+
az containerapp hostname delete \
250+
--name "$TF_VAR_app_name" \
251+
--resource-group "$TF_VAR_resource_group_name" \
252+
--hostname "$TF_VAR_custom_domain" --yes 2>/dev/null || true
253+
254+
# Find the cert by subject name and delete it
255+
AZURE_CERT_NAME=$(az containerapp env certificate list \
217256
--name "$ENV_NAME" \
218257
--resource-group "$TF_VAR_resource_group_name" \
219-
--certificate "$AZURE_CERT_NAME" --yes 2>/dev/null || true
220-
fi
258+
--query "[?properties.subjectName=='$TF_VAR_custom_domain'].name | [0]" \
259+
-o tsv 2>/dev/null || true)
260+
if [[ -n "$AZURE_CERT_NAME" && "$AZURE_CERT_NAME" != "None" ]]; then
261+
echo "Deleting Azure cert: $AZURE_CERT_NAME"
262+
az containerapp env certificate delete \
263+
--name "$ENV_NAME" \
264+
--resource-group "$TF_VAR_resource_group_name" \
265+
--certificate "$AZURE_CERT_NAME" --yes 2>/dev/null || true
266+
fi
221267
222-
# Remove stale TF state entries so Terraform creates fresh resources
223-
terraform state rm 'azurerm_container_app_custom_domain.this[0]' 2>/dev/null || true
224-
terraform state rm 'azurerm_container_app_environment_managed_certificate.this[0]' 2>/dev/null || true
225-
echo "Cleanup done. Terraform will create cert and domain binding from scratch."
226-
else
227-
echo "Cert already TF-managed as '$EXPECTED_CERT_NAME'. No cleanup needed."
268+
# Remove stale TF state entries so Terraform creates fresh resources
269+
terraform state rm 'azurerm_container_app_custom_domain.this[0]' 2>/dev/null || true
270+
terraform state rm 'azurerm_container_app_environment_managed_certificate.this[0]' 2>/dev/null || true
271+
echo "Cleanup done. Terraform will create cert and domain binding from scratch."
272+
fi
228273
fi
229274
230275
- name: Terraform plan
@@ -240,6 +285,7 @@ jobs:
240285
TF_VAR_session_secret: ${{ secrets.SHARING_SESSION_SECRET }}
241286
TF_VAR_allowed_github_org: ${{ vars.SHARING_ALLOWED_GITHUB_ORG }}
242287
TF_VAR_github_org_check_token: ${{ secrets.ORG_CHECK_TOKEN }}
288+
TF_VAR_admin_github_logins: ${{ vars.SHARING_ADMIN_GITHUB_LOGINS }}
243289
TF_VAR_min_replicas: ${{ needs.setup.outputs.min_replicas }}
244290
TF_VAR_custom_domain: ${{ vars.SHARING_CUSTOM_DOMAIN }}
245291
run: terraform plan -out=tfplan
@@ -257,6 +303,7 @@ jobs:
257303
TF_VAR_session_secret: ${{ secrets.SHARING_SESSION_SECRET }}
258304
TF_VAR_allowed_github_org: ${{ vars.SHARING_ALLOWED_GITHUB_ORG }}
259305
TF_VAR_github_org_check_token: ${{ secrets.ORG_CHECK_TOKEN }}
306+
TF_VAR_admin_github_logins: ${{ vars.SHARING_ADMIN_GITHUB_LOGINS }}
260307
TF_VAR_min_replicas: ${{ needs.setup.outputs.min_replicas }}
261308
TF_VAR_custom_domain: ${{ vars.SHARING_CUSTOM_DOMAIN }}
262309
run: terraform apply -auto-approve tfplan

sharing-server/.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ ALLOWED_GITHUB_ORG=
3434
# 3. Click "Configure SSO" next to the token and authorize it for ALLOWED_GITHUB_ORG
3535
# 4. Paste the generated token below
3636
GITHUB_ORG_CHECK_TOKEN=
37+
38+
# Optional: comma-separated GitHub logins to auto-grant admin access on the dashboard.
39+
# When set, this list is authoritative: listed users get is_admin=1, all others get is_admin=0.
40+
# Leave empty (or unset) to manage admin access manually via the SQLite database.
41+
# Example: ADMIN_GITHUB_LOGINS=alice,bob
42+
ADMIN_GITHUB_LOGINS=

sharing-server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ Open `http://localhost:3000/dashboard` in your browser to test the OAuth login f
167167
| `PORT` | ❌ | HTTP port (default: `3000`) |
168168
| `DB_PATH` | ❌ | SQLite database path (default: `/data/sharing.db`) |
169169
| `ALLOWED_GITHUB_ORG` | ❌ | If set, only members of this GitHub org can upload data |
170+
| `ADMIN_GITHUB_LOGINS` | ❌ | Comma-separated GitHub logins to auto-grant admin access (e.g. `alice,bob`). When set, this list is authoritative: listed users get admin, all others do not. Leave unset to manage admins manually via SQLite. |
170171

171172
## REST API
172173

sharing-server/infra/main.tf

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ resource "azurerm_container_app" "this" {
161161
secret_name = "org-check-token"
162162
}
163163
}
164+
# Only set ADMIN_GITHUB_LOGINS when a value is provided.
165+
dynamic "env" {
166+
for_each = var.admin_github_logins != "" ? [1] : []
167+
content {
168+
name = "ADMIN_GITHUB_LOGINS"
169+
value = var.admin_github_logins
170+
}
171+
}
164172

165173
liveness_probe {
166174
path = "/health"
@@ -200,6 +208,12 @@ resource "null_resource" "hostname_registration" {
200208
app_id = azurerm_container_app.this.id
201209
}
202210

211+
# Re-run whenever the container app is modified, because ACA updates reset
212+
# the ingress configuration and strip any previously-registered custom hostnames.
213+
lifecycle {
214+
replace_triggered_by = [azurerm_container_app.this]
215+
}
216+
203217
provisioner "local-exec" {
204218
command = "az containerapp hostname add --name '${azurerm_container_app.this.name}' --resource-group '${var.resource_group_name}' --hostname '${var.custom_domain}' 2>/dev/null || true"
205219
}
@@ -219,6 +233,13 @@ resource "azurerm_container_app_environment_managed_certificate" "this" {
219233
lifecycle {
220234
create_before_destroy = true
221235
}
236+
237+
timeouts {
238+
create = "60m"
239+
read = "5m"
240+
update = "30m"
241+
delete = "30m"
242+
}
222243
}
223244

224245
# azurerm_container_app_custom_domain cannot be used here because it only accepts
@@ -234,6 +255,12 @@ resource "null_resource" "cert_binding" {
234255
hostname = var.custom_domain
235256
}
236257

258+
# Re-run whenever the container app is modified, because ACA updates reset
259+
# the ingress configuration and strip any previously-applied cert bindings.
260+
lifecycle {
261+
replace_triggered_by = [azurerm_container_app.this]
262+
}
263+
237264
provisioner "local-exec" {
238265
environment = {
239266
CERT_ID = azurerm_container_app_environment_managed_certificate.this[0].id

sharing-server/infra/variables.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ variable "min_replicas" {
6262
default = 1
6363
}
6464

65+
variable "admin_github_logins" {
66+
description = "Optional: comma-separated GitHub logins to auto-grant admin access (e.g. 'alice,bob'). When set, this list is authoritative — listed users get is_admin=1, all others get is_admin=0. Leave empty to manage admin access manually via the SQLite database."
67+
type = string
68+
default = ""
69+
}
70+
6571
variable "tags" {
6672
description = "Azure resource tags"
6773
type = map(string)

sharing-server/src/db.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,26 @@ function initSchema(db: DatabaseSync): void {
227227
}
228228
}
229229

230+
/** Returns the parsed ADMIN_GITHUB_LOGINS env var as an array of lowercase logins. */
231+
function getAdminLoginsFromEnv(): string[] {
232+
const raw = process.env.ADMIN_GITHUB_LOGINS ?? '';
233+
return raw.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
234+
}
235+
236+
/**
237+
* Sync admin status for all existing users based on ADMIN_GITHUB_LOGINS.
238+
* When the env var is set and non-empty, it is authoritative: users in the list
239+
* get is_admin=1, all others get is_admin=0. When unset or empty, no changes are made.
240+
*/
241+
export function syncAdminLogins(): void {
242+
const logins = getAdminLoginsFromEnv();
243+
if (logins.length === 0) return;
244+
const db = getDb();
245+
const placeholders = logins.map(() => '?').join(', ');
246+
db.prepare(`UPDATE users SET is_admin = CASE WHEN LOWER(github_login) IN (${placeholders}) THEN 1 ELSE 0 END`).run(...logins);
247+
console.log(`[db] Admin sync from ADMIN_GITHUB_LOGINS: ${logins.join(', ')}`);
248+
}
249+
230250
export function upsertUser(
231251
githubId: number,
232252
login: string,
@@ -243,6 +263,11 @@ export function upsertUser(
243263
avatar_url = excluded.avatar_url,
244264
last_seen_at = datetime('now')
245265
`).run(githubId, login, name, avatarUrl);
266+
// Apply env-var admin grant immediately so new users get the right role on first login.
267+
const adminLogins = getAdminLoginsFromEnv();
268+
if (adminLogins.includes(login.toLowerCase())) {
269+
db.prepare('UPDATE users SET is_admin = 1 WHERE github_id = ?').run(githubId);
270+
}
246271
return db.prepare('SELECT * FROM users WHERE github_id = ?').get(githubId) as unknown as UserRow;
247272
}
248273

sharing-server/src/server.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { serve } from '@hono/node-server';
22
import { Hono } from 'hono';
33
import { api } from './routes/api.js';
44
import { dashboard } from './routes/dashboard.js';
5-
import { getDb, closeDb, restoreFromBackup, backupToAzureFiles } from './db.js';
5+
import { getDb, closeDb, restoreFromBackup, backupToAzureFiles, syncAdminLogins } from './db.js';
66

77
const app = new Hono();
88

@@ -35,8 +35,6 @@ function shutdown(signal: string): void {
3535
process.on('SIGTERM', () => shutdown('SIGTERM'));
3636
process.on('SIGINT', () => shutdown('SIGINT'));
3737

38-
const PORT = parseInt(process.env.PORT ?? '3000', 10);
39-
4038
async function initDbWithRetry(maxAttempts = 20): Promise<void> {
4139
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
4240
try {
@@ -50,24 +48,40 @@ async function initDbWithRetry(maxAttempts = 20): Promise<void> {
5048
await new Promise(r => setTimeout(r, delay));
5149
} else {
5250
console.error('[db] All init attempts exhausted:', err);
51+
throw err;
5352
}
5453
}
5554
}
5655
}
5756

58-
serve({ fetch: app.fetch, port: PORT }, (info) => {
59-
const org = process.env.ALLOWED_GITHUB_ORG;
60-
console.log(`Token Tracker sharing server listening on port ${info.port}`);
61-
if (org) {
62-
console.log(` Access restricted to members of GitHub org: ${org}`);
63-
} else {
64-
console.log(' Access: open to any GitHub user (set ALLOWED_GITHUB_ORG to restrict)');
65-
}
57+
const PORT = parseInt(process.env.PORT ?? '3000', 10);
58+
59+
async function main(): Promise<void> {
6660
// Restore database from Azure Files backup before opening SQLite.
6761
// SQLite runs on local container disk (/tmp/db) to avoid Azure Files SMB
6862
// locking issues. Azure Files is used only as a backup/restore store.
6963
restoreFromBackup();
70-
initDbWithRetry();
64+
await initDbWithRetry();
65+
syncAdminLogins();
7166
// Periodic backup every 5 minutes in case of unexpected SIGKILL.
7267
setInterval(() => backupToAzureFiles(), 5 * 60 * 1000).unref();
68+
69+
const org = process.env.ALLOWED_GITHUB_ORG;
70+
const adminLogins = process.env.ADMIN_GITHUB_LOGINS;
71+
serve({ fetch: app.fetch, port: PORT }, (info) => {
72+
console.log(`Token Tracker sharing server listening on port ${info.port}`);
73+
if (org) {
74+
console.log(` Access restricted to members of GitHub org: ${org}`);
75+
} else {
76+
console.log(' Access: open to any GitHub user (set ALLOWED_GITHUB_ORG to restrict)');
77+
}
78+
if (adminLogins) {
79+
console.log(` Admin logins (ADMIN_GITHUB_LOGINS): ${adminLogins}`);
80+
}
81+
});
82+
}
83+
84+
main().catch(err => {
85+
console.error('Fatal startup error:', err);
86+
process.exit(1);
7387
});

0 commit comments

Comments
 (0)