gitea: one instance, a token per person
Gitea is the first service where the server is shared but the account is not.
The owner connects the instance; every other user supplies only their own access
token and sees their own repositories, notifications and issues.
The plumbing was already per-user — createSidecarProxy injects the authenticated
caller's id as X-Officer-User, the sidecar refuses a request without it, and
service_connections is UNIQUE (user_id, service). What was wrong is that url and
token lived in the same row and a save demanded both, so a member would have had
to type the instance URL. That is worse than inconvenient: a member who can name
the URL has a per-user SSRF hop behind a settings form, and the sidecar would
dutifully attach their token to it.
`url` is now nullable, and NULL means "inherit the instance". The owner's row
carries the URL and IS the instance; everyone else's row is a credential. A
member's URL is therefore not stored rather than merely hidden — which is what
makes "members never see the instance URL" a property of the schema instead of a
filter somebody has to remember on every response.
Resolution lives in one place (getResolvedServiceCredentials / getServiceInstanceUrl)
rather than in each sidecar, so there is a single answer to "where is this
service" and no sidecar can accidentally trust a member-supplied URL.
Rules, all enforced in the sidecar rather than the UI, because a form that hides
a field is a suggestion and these are rules:
owner PUT /_config { url, token }, as before
member PUT /_config { token } only; a url in the body is REJECTED, not
ignored — ignoring it would leave someone debugging a
screen quietly talking to a different server
member, no instance 409, "the server owner has not connected a Gitea
instance yet"
member GET /_config has no url to return
owner disconnects members keep their tokens and resolve to nothing; no
instance, no service
GET /_config also now answers `instanceConfigured` and `isOwner`, which is what
lets the UI tell "you have not connected yet" from "there is nothing here to
connect to" — different screens.
memos, slskd and transmission front a single daemon and always store their own
URL; they now treat a null as a malformed row rather than reaching for somebody
else's instance.
Verified on a scratch database: pushing twice adds no diff churn beyond the known
pk_music_now_playing pair, and the resolution behaves — member GET returns a null
url, member credentials resolve to the owner's base with the member's own token,
and deleting the owner's row leaves the member's token intact but resolving to
nothing. All four live rows have a url today, so the column change applies
without touching data.
Not reachable by a real member yet: the account backstop still confines
non-owners to /api/auth + /api/music. This works the moment the capability model
lands, and until then is testable only by minting a token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -188,6 +188,8 @@ export {
|
||||
saveServiceConnection,
|
||||
deleteServiceConnection,
|
||||
recordServiceProbe,
|
||||
getServiceInstanceUrl,
|
||||
getResolvedServiceCredentials,
|
||||
} from './queries/service-connections';
|
||||
export type { ServiceName, ServiceConnection, ServiceCredentials } from './queries/service-connections';
|
||||
export {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { serviceConnections } from '../schema';
|
||||
import { getOwnerUser } from './auth';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
|
||||
// Single-connection services (transmission, slskd) for their sidecars. Callers deal in PLAINTEXT —
|
||||
@@ -13,12 +14,13 @@ import { encryptSecret, decryptSecret } from '../crypto';
|
||||
// moment someone forgot to strip it.
|
||||
|
||||
/** The services that keep a connection here. Extending it is a one-line change, not a migration. */
|
||||
export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist';
|
||||
export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer' | 'memos' | 'opengist' | 'gitea';
|
||||
|
||||
export type ServiceConnection = {
|
||||
id: number;
|
||||
service: string;
|
||||
url: string;
|
||||
/** Null on a credential-only row — see the column comment in ../schema/service-connections.ts. */
|
||||
url: string | null;
|
||||
username: string | null;
|
||||
path: string | null;
|
||||
/** Whether a secret is stored. The secret itself never crosses this boundary, not even masked. */
|
||||
@@ -30,7 +32,8 @@ export type ServiceConnection = {
|
||||
|
||||
export type ServiceCredentials = {
|
||||
id: number;
|
||||
url: string;
|
||||
/** Null when this row inherits the instance — resolve it with getServiceInstanceUrl. */
|
||||
url: string | null;
|
||||
username: string | null;
|
||||
secret: string | null;
|
||||
path: string | null;
|
||||
@@ -91,7 +94,12 @@ export async function getServiceCredentials(userId: number, service: ServiceName
|
||||
type SaveServiceConnectionParams = {
|
||||
userId: number;
|
||||
service: ServiceName;
|
||||
url: string;
|
||||
/**
|
||||
* Null writes a CREDENTIAL row — one that inherits the instance from the owner's row on read. Only
|
||||
* the inheriting kind of service does this, and only for non-owner users. See the column comment in
|
||||
* ../schema/service-connections.ts.
|
||||
*/
|
||||
url: string | null;
|
||||
username?: string | null;
|
||||
/** Encrypted before write. Undefined leaves a stored secret alone; null clears it. */
|
||||
secret?: string | null;
|
||||
@@ -151,3 +159,43 @@ export async function recordServiceProbe(userId: number, service: ServiceName, v
|
||||
.set({ version, lastSeenAt: new Date() })
|
||||
.where(and(eq(serviceConnections.userId, userId), eq(serviceConnections.service, service)));
|
||||
}
|
||||
|
||||
// ── Inherited instances ──
|
||||
//
|
||||
// Some services are ONE instance that several people authenticate to individually — Gitea is the first.
|
||||
// The owner's row carries the URL and is the instance; everyone else's row carries only their own
|
||||
// credential and leaves `url` null. See the column comment in ../schema/service-connections.ts.
|
||||
//
|
||||
// Resolution lives here rather than in each sidecar so there is one answer to "where is this service",
|
||||
// and so a sidecar cannot accidentally use a member-supplied URL — the member has not got one to supply.
|
||||
|
||||
/**
|
||||
* The instance URL for a service: whoever owns the platform configured it. Null when the owner has not
|
||||
* connected this service yet, which is the signal to refuse a member's setup rather than let them invent
|
||||
* an instance of their own.
|
||||
*/
|
||||
export async function getServiceInstanceUrl(service: ServiceName): Promise<string | null> {
|
||||
const owner = await getOwnerUser();
|
||||
if (!owner) return null;
|
||||
const [row] = await db
|
||||
.select({ url: serviceConnections.url })
|
||||
.from(serviceConnections)
|
||||
.where(and(eq(serviceConnections.userId, owner.id), eq(serviceConnections.service, service)));
|
||||
return row?.url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This user's credentials with the base resolved: their own URL if they have one (the owner, or a
|
||||
* single-daemon service), otherwise the instance the owner configured. Returns null when there is no
|
||||
* usable pairing — no row, or a row that inherits from an instance nobody has set up.
|
||||
*/
|
||||
export async function getResolvedServiceCredentials(
|
||||
userId: number,
|
||||
service: ServiceName,
|
||||
): Promise<(ServiceCredentials & { url: string }) | null> {
|
||||
const own = await getServiceCredentials(userId, service);
|
||||
if (!own) return null;
|
||||
if (own.url) return { ...own, url: own.url };
|
||||
const instance = await getServiceInstanceUrl(service);
|
||||
return instance ? { ...own, url: instance } : null;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,21 @@ export const serviceConnections = pgTable(
|
||||
/** 'transmission' | 'slskd'. Text, not an enum: adding a service should not be a schema change. */
|
||||
service: text('service').notNull(),
|
||||
// Normalized without a trailing slash before write, so `${url}/api/...` never doubles the separator.
|
||||
url: text('url').notNull(),
|
||||
//
|
||||
// NULL means "inherit the instance" — the row is a CREDENTIAL, not a connection.
|
||||
//
|
||||
// Some services are one instance that several people authenticate to individually: Gitea is the first.
|
||||
// The owner's row carries the URL and is the instance; every other user's row carries only their own
|
||||
// access token and resolves the base from the owner's row at read time. A member's URL is therefore not
|
||||
// stored rather than merely hidden, which is what makes "members never see the instance URL" a property
|
||||
// of the schema instead of a filter somebody has to remember on every response.
|
||||
//
|
||||
// It also closes the obvious hole: if a member could supply a URL, the sidecar would dutifully talk to
|
||||
// whatever host they named, which is a per-user SSRF hop wearing a settings form.
|
||||
//
|
||||
// Single-daemon services (transmission, slskd, nbxplorer) always set it. Only the inheriting kind leaves
|
||||
// it null, and only for non-owner rows.
|
||||
url: text('url'),
|
||||
/** Transmission RPC basic-auth user. Null/empty means the daemon has no auth, which is the usual case. */
|
||||
username: text('username'),
|
||||
secret: text('secret'), // encrypted: slskd API key, or Transmission's RPC password
|
||||
|
||||
Reference in New Issue
Block a user