feat: read users.json from the private repo (#111)

This commit is contained in:
Zack Pollard
2026-08-05 11:17:27 +01:00
committed by GitHub
parent 0fdcf0e1c0
commit e197169c1c
7 changed files with 99 additions and 31 deletions
+6 -2
View File
@@ -101,8 +101,12 @@ wrangler deploy
### Allowed Users List
The worker fetches the list of authorized approvers from a configurable URL. By default, it uses:
`https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json`
The worker reads the list of authorized approvers from a repository, via the GitHub App
installation, so the file can live in a private repository. Configured with
`ALLOWED_USERS_REPO` and `ALLOWED_USERS_PATH`, defaulting to
`immich-app/core-infra-tf` and `deployment/data/users.json`.
This requires the app to have `contents: read` on that repository.
The JSON structure should be:
+61 -19
View File
@@ -3,7 +3,7 @@
* Checks if a pull request has been approved by authorized users
*/
import { createOctokitForInstallation } from './auth.js';
import { createOctokitForInstallation, getInstallationId } from './auth.js';
import { CheckRunManager } from './check-runs.js';
type Role = 'immich_admin' | 'team' | 'immich' | 'contributor' | 'support' | 'futo' | 'yucca';
@@ -48,14 +48,16 @@ export interface ValidationResult {
}
export class ApprovalValidator {
private allowedUsersUrl: string;
private allowedUsersRepo: string;
private allowedUsersPath: string;
private allowedUsersCache: { users: User[]; fetchedAt: number } | null = null;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private appId: string;
private privateKey: string;
constructor(allowedUsersUrl: string, appId: string, privateKey: string) {
this.allowedUsersUrl = allowedUsersUrl;
constructor(allowedUsersRepo: string, allowedUsersPath: string, appId: string, privateKey: string) {
this.allowedUsersRepo = allowedUsersRepo;
this.allowedUsersPath = allowedUsersPath;
this.appId = appId;
this.privateKey = privateKey;
}
@@ -153,7 +155,10 @@ export class ApprovalValidator {
}
/**
* Fetch the list of allowed users from the configured URL
* Fetch the list of allowed users from the configured repository.
*
* The file lives in a private repository, so this reads it through the
* GitHub App installation rather than over an unauthenticated URL.
*/
private async getAllowedUsers(): Promise<User[]> {
// Check cache first
@@ -161,10 +166,56 @@ export class ApprovalValidator {
return this.allowedUsersCache.users;
}
const response = await fetch(this.allowedUsersUrl);
const [owner, repo] = this.allowedUsersRepo.split('/', 2);
if (!response.ok) {
console.log(`[approval] Failed to fetch allowed users (status: ${response.status})`);
try {
if (!owner || !repo) {
throw new Error(`Invalid allowed users repo: ${this.allowedUsersRepo}`);
}
const installationId = await getInstallationId(this.appId, this.privateKey, owner, repo);
const octokit = createOctokitForInstallation(this.appId, this.privateKey, installationId);
const { data } = await octokit.rest.repos.getContent({
owner,
repo,
path: this.allowedUsersPath,
mediaType: { format: 'raw' },
});
// getContent is typed as a union (file/dir/symlink/submodule); the raw
// media type gives back the file contents as a string.
const content: unknown = data;
if (typeof content !== 'string') {
throw new TypeError(`Expected file contents at ${this.allowedUsersPath}`);
}
const parsed: unknown = JSON.parse(content);
if (!Array.isArray(parsed)) {
throw new TypeError(`Expected an array of users at ${this.allowedUsersPath}`);
}
// Entries without a numeric GitHub id can never match a reviewer, and
// would throw when compared against one, so drop them here rather than
// guarding at every use site.
const users = parsed.filter(
(user): user is User =>
typeof user === 'object' && user !== null && typeof (user as User).github?.id === 'number',
);
if (users.length !== parsed.length) {
console.log(`[approval] Ignored ${parsed.length - users.length} user entries without a GitHub id`);
}
// Update cache
this.allowedUsersCache = {
users,
fetchedAt: Date.now(),
};
return users;
} catch (error) {
console.log(`[approval] Failed to fetch allowed users from ${this.allowedUsersRepo}:`, error);
// If we have cached data, use it even if expired
if (this.allowedUsersCache) {
@@ -172,19 +223,10 @@ export class ApprovalValidator {
return this.allowedUsersCache.users;
}
// Default to empty list if no cache and fetch failed
// Default to empty list if no cache and fetch failed, so an unreadable
// allowlist fails closed rather than approving.
return [];
}
const users = (await response.json()) as User[];
// Update cache
this.allowedUsersCache = {
users,
fetchedAt: Date.now(),
};
return users;
}
/**
+9 -2
View File
@@ -38,7 +38,13 @@ export default {
}
// Validate environment variables
if (!env.GITHUB_APP_ID || !env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_WEBHOOK_SECRET || !env.ALLOWED_USERS_URL) {
if (
!env.GITHUB_APP_ID ||
!env.GITHUB_APP_PRIVATE_KEY ||
!env.GITHUB_WEBHOOK_SECRET ||
!env.ALLOWED_USERS_REPO ||
!env.ALLOWED_USERS_PATH
) {
console.error('[webhook] Missing required environment variables');
return new Response('Server configuration error', { status: 500 });
}
@@ -73,7 +79,8 @@ export default {
// Initialize services
const checkRunManager = new CheckRunManager(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY);
const approvalValidator = new ApprovalValidator(
env.ALLOWED_USERS_URL,
env.ALLOWED_USERS_REPO,
env.ALLOWED_USERS_PATH,
env.GITHUB_APP_ID,
env.GITHUB_APP_PRIVATE_KEY,
);
+5 -2
View File
@@ -10,7 +10,8 @@ declare namespace Cloudflare {
GITHUB_APP_ID: string;
GITHUB_APP_PRIVATE_KEY: string;
GITHUB_WEBHOOK_SECRET: string;
ALLOWED_USERS_URL: 'https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json';
ALLOWED_USERS_REPO: 'immich-app/core-infra-tf';
ALLOWED_USERS_PATH: 'deployment/data/users.json';
ENVIRONMENT?: string;
STAGE?: string;
DEV_PR_NUMBER?: string;
@@ -21,7 +22,9 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, 'GITHUB_APP_ID' | 'ALLOWED_USERS_URL'>> {}
interface ProcessEnv extends StringifyValues<
Pick<Cloudflare.Env, 'GITHUB_APP_ID' | 'ALLOWED_USERS_REPO' | 'ALLOWED_USERS_PATH'>
> {}
}
// Begin runtime types
+2 -1
View File
@@ -10,7 +10,8 @@ compatibility_flags = ["nodejs_compat"]
# These will be set as secrets in production
[vars]
GITHUB_APP_ID = "" # Will be set when GitHub App is created
ALLOWED_USERS_URL = "https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json"
ALLOWED_USERS_REPO = "immich-app/core-infra-tf"
ALLOWED_USERS_PATH = "deployment/data/users.json"
# Secrets (set via wrangler secret put or Terraform)
# GITHUB_APP_PRIVATE_KEY - RSA private key for the GitHub App
@@ -21,8 +21,14 @@ variable "github_checks_webhook_secret" {
sensitive = true
}
variable "allowed_users_url" {
description = "URL to fetch the list of allowed users"
variable "allowed_users_repo" {
description = "Repository (owner/name) holding the list of allowed users, read via the GitHub App"
type = string
default = "https://raw.githubusercontent.com/immich-app/devtools/main/tf/deployment/data/users.json"
default = "immich-app/core-infra-tf"
}
variable "allowed_users_path" {
description = "Path to the allowed users file within allowed_users_repo"
type = string
default = "deployment/data/users.json"
}
@@ -13,9 +13,14 @@ resource "cloudflare_worker_version" "worker" {
worker_id = cloudflare_worker.worker.id
bindings = [
{
name = "ALLOWED_USERS_URL"
name = "ALLOWED_USERS_REPO"
type = "plain_text"
text = var.allowed_users_url
text = var.allowed_users_repo
},
{
name = "ALLOWED_USERS_PATH"
type = "plain_text"
text = var.allowed_users_path
},
{
name = "ENVIRONMENT"