From 384c4a72170bc137290148f247245e5355fbdab6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 3 Oct 2026 11:39:21 +0200 Subject: [PATCH] Restrict platform-list edits to admins Anyone authenticated can still read the shared platform list, but only an admin may add, rename or delete from it. - users.is_admin boolean, defaulting to false. The very first account ever registered becomes an admin automatically (CreateUser's INSERT uses NOT EXISTS (SELECT 1 FROM users) for is_admin), so there's always at least one without manual setup; promoting anyone beyond that is a manual UPDATE for now (documented in the migration's comment on the column). - GetSessionAndUser, GetUserByEmail and GetUserByID now also select is_admin, and it flows through to model.User (and so to /api/auth/{register,login,me}'s response) and to the auth middleware's authedUser. - New requireAdminMiddleware + addAdminAuth, layered on top of the existing auth middleware. Wired onto create/update/delete-platform; list/get stay on plain addAuth, since reading the list only requires being logged in. Verified end to end: the first registered user comes back is_admin:true, the second false; the non-admin gets 403 on create/update/delete-platform but 200 on list; the admin can do all of it. Co-Authored-By: Claude Sonnet 5 --- api/openapi.json | 2 +- db/migrations/20261003000000_init.up.sql | 7 ++++++ db/query.sql | 15 ++++++++----- internal/api/auth_routes.go | 4 ++-- internal/api/context.go | 11 +++++----- internal/api/middleware.go | 26 +++++++++++++++++++++- internal/api/routes.go | 16 ++++++++------ internal/api/routes_test.go | 28 +++++++++++++++++++++--- internal/db/models.go | 1 + internal/db/querier.go | 3 +++ internal/db/query.sql.go | 23 ++++++++++++++----- internal/model/model.go | 9 ++++---- 12 files changed, 110 insertions(+), 35 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 8be1d82..4a277ef 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","email","name"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} +{"components":{"schemas":{"AuthResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/AuthResponse.json"],"format":"uri","readOnly":true,"type":"string"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/User"}},"required":["token","user"],"type":"object"},"ErrorDetail":{"additionalProperties":false,"properties":{"location":{"description":"Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'","type":"string"},"message":{"description":"Error message text","type":"string"},"value":{"description":"The value at the given location"}},"type":"object"},"ErrorModel":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/ErrorModel.json"],"format":"uri","readOnly":true,"type":"string"},"detail":{"description":"A human-readable explanation specific to this occurrence of the problem.","examples":["Property foo is required but is missing."],"type":"string"},"errors":{"description":"Optional list of individual error details","items":{"$ref":"#/components/schemas/ErrorDetail"},"type":["array","null"]},"instance":{"description":"A URI reference that identifies the specific occurrence of the problem.","examples":["https://example.com/error-log/abc123"],"format":"uri","type":"string"},"status":{"description":"HTTP status code","examples":[400],"format":"int64","type":"integer"},"title":{"description":"A short, human-readable summary of the problem type. This value should not change between occurrences of the error.","examples":["Bad Request"],"type":"string"},"type":{"default":"about:blank","description":"A URI reference to human-readable documentation for the error.","examples":["https://example.com/errors/example"],"format":"uri","type":"string"}},"type":"object"},"Game":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Game.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"id":{"type":"string"},"name":{"type":"string"},"platform_id":{"type":"string"},"platform_name":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","type":"integer"}},"required":["id","name","platform_id","platform_name","score","release_date"],"type":"object"},"GameInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/GameInput.json"],"format":"uri","readOnly":true,"type":"string"},"finished":{"description":"YYYY-MM-DD (optional)","format":"date","type":"string"},"name":{"minLength":1,"type":"string"},"platform_id":{"type":"string"},"release_date":{"description":"YYYY-MM-DD","format":"date","type":"string"},"score":{"format":"int32","maximum":100,"minimum":0,"type":"integer"}},"required":["name","platform_id","score","release_date"],"type":"object"},"Get-helloResponse":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Get-helloResponse.json"],"format":"uri","readOnly":true,"type":"string"},"message":{"type":"string"}},"required":["message"],"type":"object"},"LoginInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/LoginInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"password":{"type":"string"}},"required":["email","password"],"type":"object"},"Platform":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/Platform.json"],"format":"uri","readOnly":true,"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"PlatformInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/PlatformInput.json"],"format":"uri","readOnly":true,"type":"string"},"name":{"description":"Name of the platform","minLength":1,"type":"string"}},"required":["name"],"type":"object"},"RegisterInput":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/RegisterInput.json"],"format":"uri","readOnly":true,"type":"string"},"email":{"format":"email","type":"string"},"name":{"minLength":1,"type":"string"},"password":{"minLength":8,"type":"string"}},"required":["email","password","name"],"type":"object"},"User":{"additionalProperties":false,"properties":{"$schema":{"description":"A URL to the JSON Schema for this object.","examples":["https://example.com/schemas/User.json"],"format":"uri","readOnly":true,"type":"string"},"bio":{"type":"string"},"email":{"type":"string"},"id":{"type":"string"},"is_admin":{"type":"boolean"},"name":{"type":"string"}},"required":["id","email","name","is_admin"],"type":"object"}}},"info":{"title":"Completed API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/auth/login":{"post":{"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log in to an existing account"}},"/api/auth/logout":{"post":{"operationId":"logout","responses":{"204":{"description":"No Content","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Log out of the current session"}},"/api/auth/me":{"get":{"operationId":"get-me","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get the current user"}},"/api/auth/register":{"post":{"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}},"description":"OK","headers":{"Set-Cookie":{"schema":{"type":"string"}}}},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Register a new account"}},"/api/games":{"get":{"operationId":"list-games","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Game"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Games"},"post":{"operationId":"create-game","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Game"}},"/api/games/{id}":{"delete":{"operationId":"delete-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Game"},"get":{"operationId":"get-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Game"},"put":{"operationId":"update-game","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GameInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Game"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Game"}},"/api/platforms":{"get":{"operationId":"list-platforms","responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/Platform"},"type":["array","null"]}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"List Platforms"},"post":{"operationId":"create-platform","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Create Platform"}},"/api/platforms/{id}":{"delete":{"operationId":"delete-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Delete Platform"},"get":{"operationId":"get-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Get Platform"},"put":{"operationId":"update-platform","parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PlatformInput"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Platform"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Update Platform"}},"/hello":{"get":{"description":"Returns a friendly hello message.","operationId":"get-hello","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Get-helloResponse"}}},"description":"OK"},"default":{"content":{"application/problem+json":{"schema":{"$ref":"#/components/schemas/ErrorModel"}}},"description":"Error"}},"summary":"Say Hello"}}}} diff --git a/db/migrations/20261003000000_init.up.sql b/db/migrations/20261003000000_init.up.sql index 345dbba..0121f53 100644 --- a/db/migrations/20261003000000_init.up.sql +++ b/db/migrations/20261003000000_init.up.sql @@ -14,6 +14,13 @@ CREATE TABLE users ( password_hash text NOT NULL, name text NOT NULL, bio text, + -- Only admins may add/rename/delete from the shared platform list (see + -- the platform table below); everyone can read it. The first account + -- ever registered becomes an admin automatically (see CreateUser in + -- db/query.sql) so there's always at least one without manual setup; + -- promoting anyone beyond that is a manual + -- `UPDATE users SET is_admin = true WHERE email = ...` for now. + is_admin boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); diff --git a/db/query.sql b/db/query.sql index a19efd9..a1c84a3 100644 --- a/db/query.sql +++ b/db/query.sql @@ -1,18 +1,21 @@ -- name: CreateUser :one +-- The first account ever registered becomes an admin automatically; see +-- the is_admin column's comment in db/migrations for why and the race this +-- accepts. Everyone after that registers as a non-admin. INSERT INTO users ( - email, password_hash, name, bio + email, password_hash, name, bio, is_admin ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users) ) -RETURNING id, email, name, bio, created_at; +RETURNING id, email, name, bio, is_admin, created_at; -- name: GetUserByEmail :one -SELECT id, email, password_hash, name, bio, created_at +SELECT id, email, password_hash, name, bio, is_admin, created_at FROM users WHERE email = $1; -- name: GetUserByID :one -SELECT id, email, name, bio, created_at +SELECT id, email, name, bio, is_admin, created_at FROM users WHERE id = $1; @@ -24,7 +27,7 @@ INSERT INTO sessions ( ); -- name: GetSessionAndUser :one -SELECT users.id, users.email, users.name, users.bio, sessions.expires_at +SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at FROM sessions JOIN users ON users.id = sessions.user_id WHERE sessions.token_hash = $1 AND sessions.expires_at > now(); diff --git a/internal/api/auth_routes.go b/internal/api/auth_routes.go index a74175c..4e66c1c 100644 --- a/internal/api/auth_routes.go +++ b/internal/api/auth_routes.go @@ -93,7 +93,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { SetCookie: sessionCookie(token), Body: model.AuthResponse{ Token: token, - User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin}, }, }, nil }) @@ -126,7 +126,7 @@ func registerAuthRoutes(api huma.API, queries db.Querier) { SetCookie: sessionCookie(token), Body: model.AuthResponse{ Token: token, - User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name}, + User: model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin}, }, }, nil }) diff --git a/internal/api/context.go b/internal/api/context.go index 2540fe7..f2255d5 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -11,10 +11,11 @@ import ( // authedUser is what AuthMiddleware stores in the request context after // validating a session token. Handlers read it with userFromContext. type authedUser struct { - ID uuid.UUID - Email string - Name string - Bio string + ID uuid.UUID + Email string + Name string + Bio string + IsAdmin bool } type ctxKey string @@ -22,7 +23,7 @@ type ctxKey string const userCtxKey ctxKey = "completed.user" func (u authedUser) toModel() model.User { - return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio} + return model.User{ID: u.ID.String(), Email: u.Email, Name: u.Name, Bio: u.Bio, IsAdmin: u.IsAdmin} } // userFromContext returns the authenticated user set by AuthMiddleware. It diff --git a/internal/api/middleware.go b/internal/api/middleware.go index ba0998c..5f3a83b 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -64,7 +64,7 @@ func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func // Best-effort; a failure to record activity shouldn't fail the request. _ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token)) - user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String} + user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String, IsAdmin: row.IsAdmin} newCtx := huma.WithValue(ctx, userCtxKey, user) newCtx = huma.WithValue(newCtx, tokenCtxKey, token) next(newCtx) @@ -77,6 +77,30 @@ func addAuth(op *huma.Operation, api huma.API, queries db.Querier) { op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries)) } +// requireAdminMiddleware runs after newAuthMiddleware (see addAdminAuth) and +// rejects the request unless the authenticated user is an admin. It reads +// the context value that middleware set directly, rather than calling +// userFromContext, so a wiring mistake (this middleware attached without +// the auth one ahead of it) fails the request with 403 instead of panicking. +func requireAdminMiddleware(api huma.API) func(huma.Context, func(huma.Context)) { + return func(ctx huma.Context, next func(huma.Context)) { + u, ok := ctx.Context().Value(userCtxKey).(authedUser) + if !ok || !u.IsAdmin { + huma.WriteErr(api, ctx, 403, "admin access required") + return + } + next(ctx) + } +} + +// addAdminAuth attaches both the auth and the admin-only middleware to op, +// for routes that mutate data every user can see (the shared platform +// list) but only an admin should be able to change. +func addAdminAuth(op *huma.Operation, api huma.API, queries db.Querier) { + addAuth(op, api, queries) + op.Middlewares = append(op.Middlewares, requireAdminMiddleware(api)) +} + // tokenCtxKey holds the raw bearer/cookie token for the current request, so // the logout handler can hash and delete the exact session that was used // without re-parsing the request. diff --git a/internal/api/routes.go b/internal/api/routes.go index 19e6da0..1773fbe 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -42,10 +42,12 @@ func RegisterRoutes(api huma.API, queries db.Querier) { // --- Platform Routes --- // -// Platforms are a shared list: every authenticated user reads and writes -// the same rows, unlike games, which are private per user. Routes still -// require auth (so the list isn't public), but none of the queries below -// filter by user_id, because platform has no such column. +// Platforms are a shared list: every authenticated user reads the same +// rows, unlike games, which are private per user. None of the queries +// below filter by user_id, because platform has no such column. Reading +// the list only requires being logged in (addAuth); adding, renaming or +// deleting a platform requires being an admin (addAdminAuth), since that +// changes what everyone else sees. func registerPlatformRoutes(api huma.API, queries db.Querier) { listOp := huma.Operation{ @@ -90,7 +92,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Path: "/api/platforms", Summary: "Create Platform", } - addAuth(&createOp, api, queries) + addAdminAuth(&createOp, api, queries) huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) { platform, err := queries.CreatePlatform(ctx, input.Body.Name) if err != nil { @@ -105,7 +107,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Path: "/api/platforms/{id}", Summary: "Update Platform", } - addAuth(&updateOp, api, queries) + addAdminAuth(&updateOp, api, queries) huma.Register(api, updateOp, func(ctx context.Context, input *struct { ID uuid.UUID `path:"id"` Body model.PlatformInput @@ -126,7 +128,7 @@ func registerPlatformRoutes(api huma.API, queries db.Querier) { Path: "/api/platforms/{id}", Summary: "Delete Platform", } - addAuth(&deleteOp, api, queries) + addAdminAuth(&deleteOp, api, queries) huma.Register(api, deleteOp, func(ctx context.Context, input *struct { ID uuid.UUID `path:"id"` }) (*struct{}, error) { diff --git a/internal/api/routes_test.go b/internal/api/routes_test.go index 713e365..f4c8bab 100644 --- a/internal/api/routes_test.go +++ b/internal/api/routes_test.go @@ -43,6 +43,16 @@ func authedMock() *MockQuerier { } } +// adminMock is authedMock for a session belonging to an admin, for the +// platform-mutation routes that require one. +func adminMock() *MockQuerier { + m := authedMock() + m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { + return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil + } + return m +} + func authedRequest(method, path, body string) *http.Request { var req *http.Request if body == "" { @@ -118,7 +128,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { // db/query.sql's DeletePlatform comment), so the handler has to notice // the 0-rows result itself and turn it into 409, not just pass through // whatever error (if any) the query returns. - mockDB := authedMock() + mockDB := adminMock() mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{ID: id, Name: "PC"}, nil } @@ -134,7 +144,7 @@ func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { } func TestDeletePlatformNotFound(t *testing.T) { - mockDB := authedMock() + mockDB := adminMock() mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{}, pgx.ErrNoRows } @@ -160,7 +170,7 @@ func TestCreateGameRejectsScoreOutOfRange(t *testing.T) { } func TestCreatePlatform(t *testing.T) { - mockDB := authedMock() + mockDB := adminMock() mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) { return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil } @@ -173,6 +183,18 @@ func TestCreatePlatform(t *testing.T) { assert.Contains(t, rec.Body.String(), "Sega Genesis") } +func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) { + mockDB := authedMock() + // No CreatePlatformFunc set: the admin check must reject the request + // before the handler ever calls the database, or this test would panic. + + e, _ := newTestAPI(mockDB) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`)) + + assert.Equal(t, http.StatusForbidden, rec.Code) +} + func TestListPlatformsIsSharedAcrossUsers(t *testing.T) { // Unlike games, the platform list has no user_id to scope by: every // authenticated user sees the same rows. ListPlatformsFunc taking no diff --git a/internal/db/models.go b/internal/db/models.go index 4f80886..f8c52ab 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -44,6 +44,7 @@ type User struct { PasswordHash string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz } diff --git a/internal/db/querier.go b/internal/db/querier.go index 05a6900..4293629 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -14,6 +14,9 @@ type Querier interface { CreateGame(ctx context.Context, arg CreateGameParams) (CreateGameRow, error) CreatePlatform(ctx context.Context, name string) (CreatePlatformRow, error) CreateSession(ctx context.Context, arg CreateSessionParams) error + // The first account ever registered becomes an admin automatically; see + // the is_admin column's comment in db/migrations for why and the race this + // accepts. Everyone after that registers as a non-admin. CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) DeleteGame(ctx context.Context, arg DeleteGameParams) (int64, error) // Soft delete, so the FK on game.platform_id is never touched and never diff --git a/internal/db/query.sql.go b/internal/db/query.sql.go index aed90ae..c76f01a 100644 --- a/internal/db/query.sql.go +++ b/internal/db/query.sql.go @@ -110,11 +110,11 @@ func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) er const createUser = `-- name: CreateUser :one INSERT INTO users ( - email, password_hash, name, bio + email, password_hash, name, bio, is_admin ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, NOT EXISTS (SELECT 1 FROM users) ) -RETURNING id, email, name, bio, created_at +RETURNING id, email, name, bio, is_admin, created_at ` type CreateUserParams struct { @@ -129,9 +129,13 @@ type CreateUserRow struct { Email string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz } +// The first account ever registered becomes an admin automatically; see +// the is_admin column's comment in db/migrations for why and the race this +// accepts. Everyone after that registers as a non-admin. func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) { row := q.db.QueryRow(ctx, createUser, arg.Email, @@ -145,6 +149,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU &i.Email, &i.Name, &i.Bio, + &i.IsAdmin, &i.CreatedAt, ) return i, err @@ -259,7 +264,7 @@ func (q *Queries) GetPlatform(ctx context.Context, id uuid.UUID) (GetPlatformRow } const getSessionAndUser = `-- name: GetSessionAndUser :one -SELECT users.id, users.email, users.name, users.bio, sessions.expires_at +SELECT users.id, users.email, users.name, users.bio, users.is_admin, sessions.expires_at FROM sessions JOIN users ON users.id = sessions.user_id WHERE sessions.token_hash = $1 AND sessions.expires_at > now() @@ -270,6 +275,7 @@ type GetSessionAndUserRow struct { Email string Name string Bio pgtype.Text + IsAdmin bool ExpiresAt pgtype.Timestamptz } @@ -281,13 +287,14 @@ func (q *Queries) GetSessionAndUser(ctx context.Context, tokenHash []byte) (GetS &i.Email, &i.Name, &i.Bio, + &i.IsAdmin, &i.ExpiresAt, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, password_hash, name, bio, created_at +SELECT id, email, password_hash, name, bio, is_admin, created_at FROM users WHERE email = $1 ` @@ -298,6 +305,7 @@ type GetUserByEmailRow struct { PasswordHash string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz } @@ -310,13 +318,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEm &i.PasswordHash, &i.Name, &i.Bio, + &i.IsAdmin, &i.CreatedAt, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, name, bio, created_at +SELECT id, email, name, bio, is_admin, created_at FROM users WHERE id = $1 ` @@ -326,6 +335,7 @@ type GetUserByIDRow struct { Email string Name string Bio pgtype.Text + IsAdmin bool CreatedAt pgtype.Timestamptz } @@ -337,6 +347,7 @@ func (q *Queries) GetUserByID(ctx context.Context, id uuid.UUID) (GetUserByIDRow &i.Email, &i.Name, &i.Bio, + &i.IsAdmin, &i.CreatedAt, ) return i, err diff --git a/internal/model/model.go b/internal/model/model.go index 6903f92..a70763f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -5,10 +5,11 @@ package model // User is the public view of an account; it never carries the password hash. type User struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Bio string `json:"bio,omitempty"` + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Bio string `json:"bio,omitempty"` + IsAdmin bool `json:"is_admin"` } type RegisterInput struct {