From 1543c0d81eb179ca989f4529b1b41058da98e840 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers Date: Tue, 8 Sep 2026 18:44:33 +0200 Subject: [PATCH] feat(auth): gate overleaf behind a service permission overleaf.jorisjonkers.dev shipped in fleet-infra#216 behind the forward-auth middleware, but the host resolved to no ServicePermission -- and fromHost returning null makes verify() skip the check entirely. The practical gate was therefore "any authenticated user", not "users who were granted it", which is weaker than every other forward-auth host on the cluster. Overleaf Community Edition has no SSO of its own -- SAML, LDAP and OIDC are all Server Pro features -- so unlike NOTES and HERMES, which enforce their grant at the authorization endpoint through their own OIDC client, this entry is the only per-user gate in front of it. This is a revocation, not an addition. No existing user holds a SERVICE_OVERLEAF row, and only ROLE_ADMIN bypasses the check, so everyone except admins loses access the moment it deploys. Grant before anyone relies on it. Also corrects the HERMES comment, which claimed a missing entry meant "every request is denied". It is the reverse -- a missing entry allows every authenticated user -- and that misreading is exactly what made the overleaf gap easy to miss. No migration: user_service_permissions.service is a VARCHAR(50) with no check constraint, so a new name needs no schema change. The published OpenAPI spec does not enumerate the permission names either, so the contract export is unaffected. Verified: 175/175 tests including the two new fromHost cases, ktlintCheck and detekt clean. --- .../auth/domain/model/ServicePermission.kt | 30 +++++++++++++++---- .../domain/model/ServicePermissionTest.kt | 2 ++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt index 98c6554..3af3f6b 100644 --- a/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt +++ b/api/src/main/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermission.kt @@ -60,12 +60,32 @@ enum class ServicePermission( // the case where someone already holds a valid authorization code. NOTES("notes"), - // Hermes Agent's web dashboard at hermes.jorisjonkers.dev. LAN-only - // (traefik-lan) and additionally gated by Hermes' own basic auth, but - // forward-auth still resolves the permission from the host, so the entry - // is required or every request is denied. Grant sparingly: the dashboard - // is a control plane for an agent that executes shell commands. + // Hermes Agent's web dashboard at hermes.jorisjonkers.dev. Its routes carry + // no forward-auth at all -- Hermes runs its own OIDC client, and a + // middleware in front would intercept /auth/callback -- so the grant is + // enforced at the authorization endpoint via DOWNSTREAM_CLIENT_PERMISSIONS, + // exactly as NOTES is. Grant sparingly: the dashboard is a control plane + // for an agent that executes shell commands. + // + // The previous comment here said the entry was "required or every request + // is denied". That is backwards: fromHost returns null for an unlisted + // subdomain and verify() skips the check entirely, so a missing entry + // ALLOWS every authenticated user rather than denying them. Which is + // exactly how overleaf shipped, and why it is being added below. HERMES("hermes"), + + // Overleaf Community Edition at overleaf.jorisjonkers.dev. Unlike NOTES and + // HERMES, this one really is enforced here: Community Edition has no SSO of + // its own -- SAML, LDAP and OIDC are all Server Pro -- so the route carries + // the forward-auth middleware and this entry is the only per-user gate in + // front of it. + // + // Without this entry the host resolved to null and every authenticated user + // reached Overleaf, which is how it shipped in fleet-infra#216. Adding it + // revokes that access from everyone at once: existing users hold no + // SERVICE_OVERLEAF row, and only ROLE_ADMIN bypasses the check. Grant + // before anyone relies on it. + OVERLEAF("overleaf"), ; val subdomains: Set = subdomains.toSet() diff --git a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt index 7470e38..d7d3669 100644 --- a/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt +++ b/api/src/test/kotlin/com/jorisjonkers/personalstack/auth/domain/model/ServicePermissionTest.kt @@ -38,6 +38,8 @@ class ServicePermissionTest { "notes.jorisjonkers.test, NOTES", "hermes.jorisjonkers.dev, HERMES", "hermes.jorisjonkers.test, HERMES", + "overleaf.jorisjonkers.dev, OVERLEAF", + "overleaf.jorisjonkers.test, OVERLEAF", ) fun `fromHost resolves production and local dev hostnames`( host: String,