mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 08:17:26 +01:00
feat: add scoped repository maintenance access for LLM clients (#913)
* feat: add scoped repository maintenance access for LLM clients * fix: translate maintenance API errors
This commit is contained in:
1 parent
6cf5445972
commit
6099356e0e
13 files changed
+1219
-17
No files matched your search
@@ -36,6 +36,9 @@ const UserSchema = new Schema({
|
||||
name: { type: String },
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
lastUsedAt: { type: Date },
|
||||
scope: { type: String, enum: ["repository-maintenance"] },
|
||||
repoIds: { type: [String], default: undefined },
|
||||
expiresAt: { type: Date },
|
||||
},
|
||||
],
|
||||
photo: String,
|
||||
|
||||
@@ -19,6 +19,9 @@ export interface IUser {
|
||||
name?: string;
|
||||
createdAt?: Date;
|
||||
lastUsedAt?: Date;
|
||||
scope?: "repository-maintenance";
|
||||
repoIds?: string[];
|
||||
expiresAt?: Date;
|
||||
}[];
|
||||
notificationEmail?: string;
|
||||
emailPromptNever?: boolean;
|
||||
|
||||
+35
-10
@@ -16,7 +16,10 @@ import * as passport from "passport";
|
||||
import { connect } from "./database";
|
||||
import { startTemporaryStorageMaintenance } from "../core/temporary-storage";
|
||||
import { initSession, router as connectionRouter } from "./routes/connection";
|
||||
import maintenanceRouter from "./routes/maintenance";
|
||||
import maintenanceTokens from "./routes/maintenance-tokens";
|
||||
import { bearerTokenAuth } from "./routes/token-auth";
|
||||
import { tokenAttemptLimitOptions, maintenanceRefreshLimitOptions } from "./routes/maintenance-limits";
|
||||
import router from "./routes";
|
||||
import {
|
||||
conferenceStatusCheck,
|
||||
@@ -149,12 +152,6 @@ export default async function start() {
|
||||
// client-spoofable header.
|
||||
app.set("trust proxy", resolveTrustProxy(config.TRUST_PROXY));
|
||||
|
||||
// handle session and connection
|
||||
app.use(initSession());
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
app.use(bearerTokenAuth);
|
||||
|
||||
startWorker();
|
||||
|
||||
const redisClient = createClient({
|
||||
@@ -169,6 +166,32 @@ export default async function start() {
|
||||
|
||||
await redisClient.connect();
|
||||
|
||||
// Charge failed and forbidden bearer requests before their database lookup.
|
||||
app.use(rateLimit({
|
||||
...tokenAttemptLimitOptions,
|
||||
store: new RedisStore({
|
||||
prefix: "token-attempt:",
|
||||
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
|
||||
}),
|
||||
}));
|
||||
// Bearer attempts are limited before session deserialization as well.
|
||||
app.use(initSession());
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
app.use(bearerTokenAuth);
|
||||
app.use(rateLimit({
|
||||
...maintenanceRefreshLimitOptions,
|
||||
store: new RedisStore({
|
||||
prefix: "maintenance-refresh:",
|
||||
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
|
||||
}),
|
||||
}));
|
||||
|
||||
// All maintenance tokens for an account share a budget across client IPs.
|
||||
const apiRateKey = (req: express.Request, res: express.Response) =>
|
||||
res.locals.maintenanceOwnerId
|
||||
? `maintenance:${res.locals.maintenanceOwnerId}`
|
||||
: requestRateLimitKey(req);
|
||||
const rate = rateLimit({
|
||||
store: new RedisStore({
|
||||
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
|
||||
@@ -177,7 +200,7 @@ export default async function start() {
|
||||
skip: async (request: express.Request, _response: express.Response) => {
|
||||
try {
|
||||
const user = await getUser(request);
|
||||
if (user && user.isAdmin) return true;
|
||||
if (user && user.isAdmin && !_response.locals.maintenanceToken) return true;
|
||||
} catch {
|
||||
// ignore: user not connected
|
||||
}
|
||||
@@ -193,7 +216,7 @@ export default async function start() {
|
||||
// if not logged in, limit to half the rate
|
||||
return config.RATE_LIMIT / 2;
|
||||
},
|
||||
keyGenerator: requestRateLimitKey,
|
||||
keyGenerator: apiRateKey,
|
||||
standardHeaders: "draft-6",
|
||||
legacyHeaders: false,
|
||||
handler: sendRequestRateLimit,
|
||||
@@ -203,14 +226,14 @@ export default async function start() {
|
||||
delayAfter: 50,
|
||||
delayMs: () => 150,
|
||||
maxDelayMs: 5000,
|
||||
keyGenerator: requestRateLimitKey,
|
||||
keyGenerator: apiRateKey,
|
||||
});
|
||||
const webViewSpeedLimiter = slowDown({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
delayAfter: 200,
|
||||
delayMs: () => 150,
|
||||
maxDelayMs: 5000,
|
||||
keyGenerator: requestRateLimitKey,
|
||||
keyGenerator: apiRateKey,
|
||||
});
|
||||
|
||||
app.use("/github", rate, speedLimiter, githubAppRouter);
|
||||
@@ -220,6 +243,8 @@ export default async function start() {
|
||||
const apiRouter = express.Router();
|
||||
app.use("/api", rate, apiRouter);
|
||||
|
||||
apiRouter.use("/maintenance", speedLimiter, maintenanceRouter);
|
||||
apiRouter.use("/user/maintenance-tokens", maintenanceTokens);
|
||||
apiRouter.use("/admin", router.admin);
|
||||
apiRouter.use("/options", router.option);
|
||||
apiRouter.use("/conferences", router.conference);
|
||||
|
||||
@@ -252,7 +252,11 @@ router.post(
|
||||
const model = await UserModel.findOne({
|
||||
"apiTokens.tokenHash": hashToken(token),
|
||||
});
|
||||
if (!model) return res.status(401).json({ error: "invalid_token" });
|
||||
const entry = model?.apiTokens?.find(t => t.tokenHash === hashToken(token));
|
||||
if (!model || !entry || entry.scope ||
|
||||
(entry.expiresAt && entry.expiresAt.getTime() <= Date.now())) {
|
||||
return res.status(401).json({ error: "invalid_token" });
|
||||
}
|
||||
if (isDisabledAccount(model.status)) {
|
||||
return res.status(model.status === "banned" ? 403 : 401).json({
|
||||
error: model.status === "banned" ? "user_banned" : "not_connected",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Options } from "express-rate-limit";
|
||||
import { requestRateLimitKey } from "../rate-limit-key";
|
||||
|
||||
// Redis stores are supplied by the server. These policies also run in tests
|
||||
// with the in-memory store, through real HTTP requests and the auth middleware.
|
||||
export const tokenAttemptLimitOptions: Partial<Options> = {
|
||||
windowMs: 60_000,
|
||||
limit: 120,
|
||||
skip: req => !req.headers.authorization,
|
||||
keyGenerator: requestRateLimitKey,
|
||||
standardHeaders: "draft-6",
|
||||
legacyHeaders: false,
|
||||
message: { error: "token_attempts_rate_limited" },
|
||||
};
|
||||
|
||||
export const maintenanceRefreshLimitOptions: Partial<Options> = {
|
||||
windowMs: 60_000,
|
||||
limit: 1,
|
||||
skip: (req, res) => !res.locals.maintenanceToken || req.method !== "POST" ||
|
||||
!/^\/api\/repo\/[a-zA-Z0-9_-]+\/refresh\/?$/.test(req.path),
|
||||
// Share the cooldown across this account's tokens and client IPs. Keep
|
||||
// different accounts separate so a removed coauthor cannot block the owner.
|
||||
keyGenerator: (req, res) =>
|
||||
`${res.locals.maintenanceOwnerId}:${req.path.split("/")[3].toLowerCase()}`,
|
||||
standardHeaders: "draft-6",
|
||||
legacyHeaders: false,
|
||||
message: { error: "refresh_rate_limited" },
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as express from "express";
|
||||
import { Types } from "mongoose";
|
||||
import { getUser, handleError, isOwnerCoauthorOrAdmin } from "./route-utils";
|
||||
import { generateToken, hashToken } from "./token-auth";
|
||||
import UserModel from "../../core/model/users/users.model";
|
||||
import { getRepository } from "../database";
|
||||
import AnonymousError from "../../core/AnonymousError";
|
||||
|
||||
const router = express.Router();
|
||||
router.use((req, res, next) => {
|
||||
res.set("Cache-Control", "no-store");
|
||||
// A restricted token must never mint credentials or become a session.
|
||||
if (req.headers.authorization || !req.isAuthenticated()) {
|
||||
return res.status(401).json({ error: "session_required" });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
res.json((user.model.apiTokens || []).filter(t => t.scope === "repository-maintenance")
|
||||
.map(t => ({ id: t._id, name: t.name, repoIds: t.repoIds, expiresAt: t.expiresAt,
|
||||
createdAt: t.createdAt, lastUsedAt: t.lastUsedAt })));
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const { name, repoIds, expiresInDays = 30 } = req.body;
|
||||
if (typeof name !== "string" || !name.trim() || name.length > 80 ||
|
||||
!Array.isArray(repoIds) || !repoIds.length || repoIds.length > 20 ||
|
||||
!repoIds.every(id => typeof id === "string" && /^[a-zA-Z0-9_-]{3,200}$/.test(id)) ||
|
||||
!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 90) {
|
||||
throw new AnonymousError("invalid_token_options", { httpStatus: 400 });
|
||||
}
|
||||
for (const id of new Set<string>(repoIds)) {
|
||||
isOwnerCoauthorOrAdmin(await getRepository(id), user);
|
||||
}
|
||||
const token = generateToken();
|
||||
const entry = { _id: new Types.ObjectId(), name: name.trim(), scope: "repository-maintenance",
|
||||
repoIds: [...new Set(repoIds)], tokenHash: hashToken(token), createdAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + expiresInDays * 86400_000) };
|
||||
const saved = await UserModel.updateOne({ _id: user.model._id,
|
||||
"apiTokens.50": { $exists: false } }, { $push: { apiTokens: entry } });
|
||||
if (!saved.matchedCount) throw new AnonymousError("token_limit_reached", { httpStatus: 409 });
|
||||
res.status(201).json({ id: entry._id, name: entry.name, repoIds: entry.repoIds,
|
||||
expiresAt: entry.expiresAt, token });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
if (!Types.ObjectId.isValid(req.params.id)) throw new AnonymousError("invalid_token_id", { httpStatus: 400 });
|
||||
const result = await UserModel.updateOne({ _id: user.model._id },
|
||||
{ $pull: { apiTokens: { _id: req.params.id, scope: "repository-maintenance" } } });
|
||||
res.json({ removed: result.modifiedCount });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
export default router;
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as express from "express";
|
||||
import { getRepo, getUser, handleError, isOwnerCoauthorOrAdmin } from "./route-utils";
|
||||
import AnonymousError from "../../core/AnonymousError";
|
||||
import RepoModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
|
||||
const router = express.Router();
|
||||
const toggles = ["update", "image", "pdf", "notebook", "link"];
|
||||
|
||||
/** Only in-place anonymization settings; never accept source or connection input. */
|
||||
export function maintenanceSettings(body: unknown): Record<string, unknown> {
|
||||
const invalid = () => new AnonymousError("invalid_settings", { httpStatus: 400 });
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw invalid();
|
||||
const entries = Object.entries(body);
|
||||
if (!entries.length) throw invalid();
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const [key, value] of entries) {
|
||||
if (key === "terms") {
|
||||
if (!Array.isArray(value) || value.length > 1000 ||
|
||||
!value.every(t => typeof t === "string" && t.length > 0 && t.length <= 1000)) throw invalid();
|
||||
} else if (!toggles.includes(key) || typeof value !== "boolean") throw invalid();
|
||||
updates[`options.${key}`] = value;
|
||||
}
|
||||
return updates;
|
||||
}
|
||||
|
||||
router.route("/repositories/:repoId")
|
||||
.all((_req, res, next) => { res.set("Cache-Control", "no-store"); next(); })
|
||||
.get(async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const repo = await getRepo(req, res, { nocheck: true });
|
||||
if (!repo) return;
|
||||
isOwnerCoauthorOrAdmin(repo, user);
|
||||
// Read saved metadata without refreshing GitHub credentials or content.
|
||||
res.json({ repoId: repo.repoId, status: repo.status, statusMessage: repo.model.statusMessage,
|
||||
statusDate: repo.model.statusDate, settingsSavedAt: repo.model.settingsSavedAt,
|
||||
source: { branch: repo.model.source.branch, commit: repo.model.source.commit },
|
||||
options: repo.model.options });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
})
|
||||
.patch(async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const repo = await getRepo(req, res, { nocheck: true });
|
||||
if (!repo) return;
|
||||
isOwnerCoauthorOrAdmin(repo, user);
|
||||
if (!["ready", "error"].includes(repo.status || "")) throw new AnonymousError("invalid_status", { httpStatus: 409 });
|
||||
repo.assertNotArchived();
|
||||
const updates = maintenanceSettings(req.body);
|
||||
const settingsSavedAt = new Date();
|
||||
const saved = await RepoModel.updateOne({ _id: repo.model._id, status: repo.model.status,
|
||||
statusDate: repo.model.statusDate,
|
||||
settingsSavedAt: repo.model.settingsSavedAt || { $exists: false } },
|
||||
{ $set: { ...updates, settingsSavedAt } }).exec();
|
||||
if (!saved.matchedCount) throw new AnonymousError("invalid_status", { httpStatus: 409 });
|
||||
res.json({ repoId: repo.repoId, status: repo.status, settingsSavedAt });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
export default router;
|
||||
@@ -16,21 +16,35 @@ export function generateToken(): string {
|
||||
|
||||
export async function bearerTokenAuth(
|
||||
req: express.Request,
|
||||
_res: express.Response,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
): Promise<void> {
|
||||
if (req.user) return next();
|
||||
|
||||
const header = req.headers["authorization"];
|
||||
if (!header || typeof header !== "string") return next();
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) return next();
|
||||
if (!match || !match[1].trim() || match[1].length > 512) {
|
||||
res.status(401).json({ error: "invalid_token" });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(match[1].trim());
|
||||
try {
|
||||
const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash });
|
||||
if (!model) return next();
|
||||
if (isDisabledAccount(model.status)) return next();
|
||||
const token = model?.apiTokens?.find(t => t.tokenHash === tokenHash);
|
||||
if (!model || !token || isDisabledAccount(model.status) ||
|
||||
(token.expiresAt && token.expiresAt.getTime() <= Date.now()) ||
|
||||
(token.scope && (!token.expiresAt || token.scope !== "repository-maintenance"))) {
|
||||
res.status(401).json({ error: "invalid_token" });
|
||||
return;
|
||||
}
|
||||
if (token.scope) {
|
||||
if (!maintenanceTokenAllows(req.method, req.path, token.repoIds || [])) {
|
||||
res.status(403).json({ error: "token_scope_forbidden" });
|
||||
return;
|
||||
}
|
||||
res.locals.maintenanceToken = true;
|
||||
res.locals.maintenanceOwnerId = String(model._id);
|
||||
}
|
||||
|
||||
// Mirror the shape produced by passport's verify() in connection.ts
|
||||
// so existing getUser()/route code works unchanged.
|
||||
@@ -48,6 +62,16 @@ export async function bearerTokenAuth(
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error("lookup failed", serializeError(err));
|
||||
res.status(503).json({ error: "authentication_unavailable" });
|
||||
return;
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
/** Exact paths keep scoped tokens out of GitHub proxy and session routes. */
|
||||
export function maintenanceTokenAllows(method: string, path: string, repoIds: string[]): boolean {
|
||||
const readOrEdit = path.match(/^\/api\/maintenance\/repositories\/([a-zA-Z0-9_-]+)\/?$/);
|
||||
if (readOrEdit && ["GET", "PATCH"].includes(method)) return repoIds.includes(readOrEdit[1]);
|
||||
const action = path.match(/^\/api\/repo\/([a-zA-Z0-9_-]+)\/(refresh|extend)\/?$/);
|
||||
return !!action && method === "POST" && repoIds.includes(action[1]);
|
||||
}
|
||||
Reference in new issue
Block a user