mirror of
https://github.com/immich-app/services.git
synced 2026-09-30 13:23:10 +08:00
feat: read users.json from the private repo (#111)
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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,22 +166,46 @@ 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})`);
|
||||
|
||||
// If we have cached data, use it even if expired
|
||||
if (this.allowedUsersCache) {
|
||||
console.log('[approval] Using cached allowed users due to fetch error');
|
||||
return this.allowedUsersCache.users;
|
||||
try {
|
||||
if (!owner || !repo) {
|
||||
throw new Error(`Invalid allowed users repo: ${this.allowedUsersRepo}`);
|
||||
}
|
||||
|
||||
// Default to empty list if no cache and fetch failed
|
||||
return [];
|
||||
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 users = (await response.json()) as User[];
|
||||
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 = {
|
||||
@@ -185,6 +214,19 @@ export class ApprovalValidator {
|
||||
};
|
||||
|
||||
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) {
|
||||
console.log('[approval] Using cached allowed users due to fetch error');
|
||||
return this.allowedUsersCache.users;
|
||||
}
|
||||
|
||||
// Default to empty list if no cache and fetch failed, so an unreadable
|
||||
// allowlist fails closed rather than approving.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user