From 6a6e95b30362372f79c9b329c43e04186f4c54b2 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Wed, 18 Mar 2026 10:29:46 -0400 Subject: [PATCH 1/6] feat(bot): api bot tokens implemented --- docker-compose.yml | 16 +- docs/schema/schema.json | 10 +- docs/swagger/docs.go | 25 ++- docs/swagger/swagger.json | 27 ++- docs/swagger/swagger.yaml | 20 +- internal/database/mocks/Querier.go | 20 +- internal/database/querier.go | 2 +- internal/database/queries.sql | 4 +- internal/database/queries.sql.go | 8 +- internal/handler/auth.go | 76 +++++-- internal/middleware/auth.go | 9 - internal/middleware/auth_human_test.go | 9 + internal/middleware/auth_m2m.go | 92 ++++---- internal/router/router.go | 95 +++++---- internal/router/router_auth_test.go | 279 +++++++++++++++++++++++++ 15 files changed, 540 insertions(+), 152 deletions(-) create mode 100644 internal/router/router_auth_test.go diff --git a/docker-compose.yml b/docker-compose.yml index 6594f6f..5ceb332 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,14 +25,14 @@ services: db: condition: service_healthy - tunnel: - image: cloudflare/cloudflared:latest - restart: unless-stopped - command: tunnel run - env_file: - - .env - depends_on: - - api + # tunnel: + # image: cloudflare/cloudflared:latest + # restart: unless-stopped + # command: tunnel run + # env_file: + # - .env + # depends_on: + # - api volumes: pgdata: diff --git a/docs/schema/schema.json b/docs/schema/schema.json index 67eaee4..737f7e8 100644 --- a/docs/schema/schema.json +++ b/docs/schema/schema.json @@ -71699,8 +71699,8 @@ "insert_into_table": null }, { - "text": "\nSELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_hash = $1 AND is_active = true", - "name": "GetBotTokenByHash", + "text": "\nSELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_id = $1", + "name": "GetBotTokenByID", "cmd": ":one", "columns": [ { @@ -71916,7 +71916,7 @@ { "number": 1, "column": { - "name": "token_hash", + "name": "token_id", "not_null": true, "is_array": false, "comment": "", @@ -71933,11 +71933,11 @@ "type": { "catalog": "", "schema": "", - "name": "text" + "name": "uuid" }, "is_sqlc_slice": false, "embed_table": null, - "original_name": "token_hash", + "original_name": "token_id", "unsigned": false, "array_dims": 0 } diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 35a1411..d6a0539 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -218,7 +218,7 @@ const docTemplate = `{ "BotToken": [] } ], - "description": "Returns information about the current bot token", + "description": "Returns information about the current bot token. Authenticate with X-Bot-Token: ., for example: curl -H 'X-Bot-Token: ' http://localhost:8080/api/v1/bot/me", "consumes": [ "application/json" ], @@ -233,7 +233,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.BotTokenResponse" + "$ref": "#/definitions/handler.BotMeResponse" } }, "401": { @@ -287,7 +287,7 @@ const docTemplate = `{ "CookieAuth": [] } ], - "description": "Creates a new bot token (requires faculty role)", + "description": "Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller.", "consumes": [ "application/json" ], @@ -1821,7 +1821,24 @@ const docTemplate = `{ "type": "string" }, "token": { - "description": "Only on creation", + "description": "Only on creation. Store it immediately; it is not returned again.", + "type": "string" + }, + "token_id": { + "type": "string" + } + } + }, + "handler.BotMeResponse": { + "type": "object", + "properties": { + "auth_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "name": { "type": "string" }, "token_id": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index f68e0ee..e57d7c0 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -212,7 +212,7 @@ "BotToken": [] } ], - "description": "Returns information about the current bot token", + "description": "Returns information about the current bot token. Authenticate with X-Bot-Token: ., for example: curl -H 'X-Bot-Token: ' http://localhost:8080/api/v1/bot/me", "consumes": [ "application/json" ], @@ -227,7 +227,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handler.BotTokenResponse" + "$ref": "#/definitions/handler.BotMeResponse" } }, "401": { @@ -281,7 +281,7 @@ "CookieAuth": [] } ], - "description": "Creates a new bot token (requires faculty role)", + "description": "Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller.", "consumes": [ "application/json" ], @@ -1815,7 +1815,24 @@ "type": "string" }, "token": { - "description": "Only on creation", + "description": "Only on creation. Store it immediately; it is not returned again.", + "type": "string" + }, + "token_id": { + "type": "string" + } + } + }, + "handler.BotMeResponse": { + "type": "object", + "properties": { + "auth_type": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "name": { "type": "string" }, "token_id": { @@ -1883,4 +1900,4 @@ "in": "cookie" } } -} \ No newline at end of file +} diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 98f40b9..a7e12ef 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -187,7 +187,18 @@ definitions: name: type: string token: - description: Only on creation + description: Only on creation. Store it immediately; it is not returned again. + type: string + token_id: + type: string + type: object + handler.BotMeResponse: + properties: + auth_type: + type: string + expires_at: + type: string + name: type: string token_id: type: string @@ -360,14 +371,15 @@ paths: get: consumes: - application/json - description: Returns information about the current bot token + description: 'Returns information about the current bot token. Authenticate with X-Bot-Token: + ., for example: curl -H ''X-Bot-Token: '' http://localhost:8080/api/v1/bot/me' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handler.BotTokenResponse' + $ref: '#/definitions/handler.BotMeResponse' "401": description: Unauthorized schema: @@ -403,7 +415,7 @@ paths: post: consumes: - application/json - description: Creates a new bot token (requires faculty role) + description: Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller. parameters: - description: Token data in: body diff --git a/internal/database/mocks/Querier.go b/internal/database/mocks/Querier.go index f8822b0..3baf283 100644 --- a/internal/database/mocks/Querier.go +++ b/internal/database/mocks/Querier.go @@ -220,27 +220,27 @@ func (_m *Querier) DeleteUser(ctx context.Context, uid uuid.UUID) error { return r0 } -// GetBotTokenByHash provides a mock function with given fields: ctx, tokenHash -func (_m *Querier) GetBotTokenByHash(ctx context.Context, tokenHash string) (database.BotToken, error) { - ret := _m.Called(ctx, tokenHash) +// GetBotTokenByID provides a mock function with given fields: ctx, tokenID +func (_m *Querier) GetBotTokenByID(ctx context.Context, tokenID uuid.UUID) (database.BotToken, error) { + ret := _m.Called(ctx, tokenID) if len(ret) == 0 { - panic("no return value specified for GetBotTokenByHash") + panic("no return value specified for GetBotTokenByID") } var r0 database.BotToken var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (database.BotToken, error)); ok { - return rf(ctx, tokenHash) + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) (database.BotToken, error)); ok { + return rf(ctx, tokenID) } - if rf, ok := ret.Get(0).(func(context.Context, string) database.BotToken); ok { - r0 = rf(ctx, tokenHash) + if rf, ok := ret.Get(0).(func(context.Context, uuid.UUID) database.BotToken); ok { + r0 = rf(ctx, tokenID) } else { r0 = ret.Get(0).(database.BotToken) } - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, tokenHash) + if rf, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = rf(ctx, tokenID) } else { r1 = ret.Error(1) } diff --git a/internal/database/querier.go b/internal/database/querier.go index fa8a0c9..1a39c35 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -22,7 +22,7 @@ type Querier interface { DeleteOrganization(ctx context.Context, oid uuid.UUID) error DeleteUser(ctx context.Context, uid uuid.UUID) error // Bot Token Queries - GetBotTokenByHash(ctx context.Context, tokenHash string) (BotToken, error) + GetBotTokenByID(ctx context.Context, tokenID uuid.UUID) (BotToken, error) GetEventByID(ctx context.Context, eid uuid.UUID) (Event, error) GetEventRegistrations(ctx context.Context, eid uuid.UUID) ([]GetEventRegistrationsRow, error) GetOrgMembers(ctx context.Context, oid uuid.UUID) ([]GetOrgMembersRow, error) diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 426505f..6c9cf49 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -134,8 +134,8 @@ ORDER BY e.event_time DESC; -- Bot Token Queries --- name: GetBotTokenByHash :one -SELECT * FROM bot_tokens WHERE token_hash = $1 AND is_active = true; +-- name: GetBotTokenByID :one +SELECT * FROM bot_tokens WHERE token_id = $1; -- name: CreateBotToken :one INSERT INTO bot_tokens (token_hash, name, created_by, expires_at) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index d9f267d..10d77b2 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -192,14 +192,14 @@ func (q *Queries) DeleteUser(ctx context.Context, uid uuid.UUID) error { return err } -const getBotTokenByHash = `-- name: GetBotTokenByHash :one +const getBotTokenByID = `-- name: GetBotTokenByID :one -SELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_hash = $1 AND is_active = true +SELECT token_id, token_hash, name, created_by, created_at, last_used_at, expires_at, is_active FROM bot_tokens WHERE token_id = $1 ` // Bot Token Queries -func (q *Queries) GetBotTokenByHash(ctx context.Context, tokenHash string) (BotToken, error) { - row := q.db.QueryRow(ctx, getBotTokenByHash, tokenHash) +func (q *Queries) GetBotTokenByID(ctx context.Context, tokenID uuid.UUID) (BotToken, error) { + row := q.db.QueryRow(ctx, getBotTokenByID, tokenID) var i BotToken err := row.Scan( &i.TokenID, diff --git a/internal/handler/auth.go b/internal/handler/auth.go index dbbc3cf..d389a90 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -45,6 +45,13 @@ type BotTokenResponse struct { IsActive bool `json:"is_active"` } +type BotMeResponse struct { + TokenID uuid.UUID `json:"token_id"` + Name string `json:"name"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + AuthType string `json:"auth_type"` +} + type CreateBotTokenRequest struct { Name string `json:"name" validate:"required,min=1,max=100"` ExpiresAt *time.Time `json:"expires_at,omitempty"` @@ -313,7 +320,10 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens [get] func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { - // TODO: Check faculty role + if !h.requireFaculty(w, r) { + return + } + tokens, err := h.queries.ListBotTokens(r.Context()) if err != nil { h.handleDBError(w, err) @@ -336,7 +346,7 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { // CreateBotToken creates a new bot token // @Summary Create bot token -// @Description Creates a new bot token (requires faculty role) +// @Description Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller. // @Tags bot // @Accept json // @Produce json @@ -347,14 +357,11 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens [post] func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { - claims, ok := middleware.GetUserClaims(r.Context()) + claims, ok := h.requireFacultyClaims(w, r) if !ok { - h.respondError(w, http.StatusUnauthorized, "Not authenticated") return } - // TODO: Check faculty role - var req CreateBotTokenRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -366,15 +373,13 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { return } - // Generate random token - rawToken, err := generateSecureToken(32) + secret, err := generateSecureToken(32) if err != nil { h.respondError(w, http.StatusInternalServerError, "Failed to generate token") return } - // Hash the token for storage - hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost) + hashedToken, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) if err != nil { h.respondError(w, http.StatusInternalServerError, "Failed to hash token") return @@ -393,6 +398,8 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { return } + rawToken := formatBotToken(token.TokenID, secret) + h.respondJSON(w, http.StatusCreated, BotTokenResponse{ TokenID: token.TokenID, Name: token.Name, @@ -416,6 +423,10 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens/{token_id} [delete] func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { + if !h.requireFaculty(w, r) { + return + } + tokenIDStr := chi.URLParam(r, "token_id") tokenID, err := uuid.Parse(tokenIDStr) if err != nil { @@ -423,8 +434,6 @@ func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { return } - // TODO: Check faculty role - if err := h.queries.RevokeBotToken(r.Context(), tokenID); err != nil { h.handleDBError(w, err) return @@ -435,19 +444,26 @@ func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { // GetBotMe returns info about the current bot token // @Summary Get bot info -// @Description Returns information about the current bot token +// @Description Returns information about the current bot token. Authenticate with X-Bot-Token: ., for example: curl -H 'X-Bot-Token: ' http://localhost:8080/api/v1/bot/me // @Tags bot // @Accept json // @Produce json -// @Success 200 {object} BotTokenResponse +// @Success 200 {object} BotMeResponse // @Failure 401 {object} ErrorResponse // @Security BotToken // @Router /bot/me [get] func (h *Handler) GetBotMe(w http.ResponseWriter, r *http.Request) { - // Token info would be in context from M2M middleware - h.respondJSON(w, http.StatusOK, map[string]string{ - "status": "authenticated", - "type": "bot", + token, ok := middleware.GetBotToken(r.Context()) + if !ok { + h.respondError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + h.respondJSON(w, http.StatusOK, BotMeResponse{ + TokenID: token.TokenID, + Name: token.Name, + ExpiresAt: token.ExpiresAt, + AuthType: middleware.GetAuthType(r.Context()), }) } @@ -608,3 +624,27 @@ func generateSecureToken(length int) (string, error) { } return hex.EncodeToString(bytes), nil } + +func formatBotToken(tokenID uuid.UUID, secret string) string { + return tokenID.String() + "." + secret +} + +func (h *Handler) requireFaculty(w http.ResponseWriter, r *http.Request) bool { + _, ok := h.requireFacultyClaims(w, r) + return ok +} + +func (h *Handler) requireFacultyClaims(w http.ResponseWriter, r *http.Request) (*middleware.UserClaims, bool) { + claims, ok := middleware.GetUserClaims(r.Context()) + if !ok { + h.respondError(w, http.StatusUnauthorized, "Not authenticated") + return nil, false + } + + if claims.Role != string(database.UserRoleFaculty) { + h.respondError(w, http.StatusForbidden, "Faculty role required") + return nil, false + } + + return claims, true +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ce7d3c7..1503c27 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -43,15 +43,6 @@ func Auth(jwtSecret string) func(http.Handler) http.Handler { } } - // Check for bot token - botToken := r.Header.Get("X-Bot-Token") - if botToken != "" { - // Bot authentication will be handled separately - ctx := context.WithValue(r.Context(), AuthTypeKey, "bot") - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - if tokenString == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return diff --git a/internal/middleware/auth_human_test.go b/internal/middleware/auth_human_test.go index b649206..2028f25 100644 --- a/internal/middleware/auth_human_test.go +++ b/internal/middleware/auth_human_test.go @@ -45,6 +45,15 @@ func TestAuth(t *testing.T) { }, expectedStatus: http.StatusUnauthorized, }, + { + name: "BotHeaderDoesNotBypassHumanAuth", + tokenSetup: func() *http.Request { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Bot-Token", "ignored") + return req + }, + expectedStatus: http.StatusUnauthorized, + }, { name: "InvalidToken", tokenSetup: func() *http.Request { diff --git a/internal/middleware/auth_m2m.go b/internal/middleware/auth_m2m.go index c2d6aab..010d776 100644 --- a/internal/middleware/auth_m2m.go +++ b/internal/middleware/auth_m2m.go @@ -2,10 +2,14 @@ package middleware import ( "context" + "errors" "net/http" + "strings" "time" "github.com/capyrpi/api/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) @@ -13,65 +17,58 @@ const BotTokenKey contextKey = "bot_token" // BotTokenInfo contains information about the authenticated bot type BotTokenInfo struct { - TokenID string - Name string + TokenID uuid.UUID + Name string + ExpiresAt *time.Time } // M2MAuth middleware validates bot tokens from X-Bot-Token header func M2MAuth(queries database.Querier) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := r.Header.Get("X-Bot-Token") - if token == "" { + rawToken := r.Header.Get("X-Bot-Token") + if rawToken == "" { http.Error(w, "Missing X-Bot-Token header", http.StatusUnauthorized) return } - // Get all active tokens and check against hash - // Note: In production with many tokens, implement a token prefix lookup - tokens, err := queries.ListBotTokens(r.Context()) + tokenID, secret, err := ParseBotToken(rawToken) if err != nil { - http.Error(w, "Internal server error", http.StatusInternalServerError) + http.Error(w, "Invalid bot token", http.StatusUnauthorized) return } - var matchedToken *database.ListBotTokensRow - for i, t := range tokens { - if !t.IsActive.Bool { - continue - } - // Check expiry - if t.ExpiresAt.Valid && t.ExpiresAt.Time.Before(time.Now()) { - continue + token, err := queries.GetBotTokenByID(r.Context(), tokenID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Invalid bot token", http.StatusUnauthorized) + return } - // Note: We need the full token row with hash for comparison - // This simplified version won't work as ListBotTokens doesn't return hash - // You'd need a GetBotTokenByHash query that iterates or uses prefix matching - _ = t - matchedToken = &tokens[i] - break + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if !token.IsActive.Bool { + http.Error(w, "Bot token is inactive", http.StatusUnauthorized) + return + } + + if token.ExpiresAt.Valid && token.ExpiresAt.Time.Before(time.Now()) { + http.Error(w, "Bot token is expired", http.StatusUnauthorized) + return } - // For now, simplified validation - in real implementation: - // 1. Hash the provided token - // 2. Look up by hash in database - // 3. Verify expiry and active status - if matchedToken == nil { - // Try bcrypt comparison against stored hashes - // This requires fetching hashes which ListBotTokens doesn't do + if !ValidateBotToken(secret, token.TokenHash) { http.Error(w, "Invalid bot token", http.StatusUnauthorized) return } - // Update last used timestamp (fire and forget) - go func() { - _ = queries.UpdateBotTokenLastUsed(context.Background(), matchedToken.TokenID) - }() + _ = queries.UpdateBotTokenLastUsed(r.Context(), token.TokenID) - // Add bot info to context ctx := context.WithValue(r.Context(), BotTokenKey, &BotTokenInfo{ - TokenID: matchedToken.TokenID.String(), - Name: matchedToken.Name, + TokenID: token.TokenID, + Name: token.Name, + ExpiresAt: botTokenExpiry(token), }) ctx = context.WithValue(ctx, AuthTypeKey, "bot") @@ -91,3 +88,26 @@ func GetBotToken(ctx context.Context) (*BotTokenInfo, bool) { info, ok := ctx.Value(BotTokenKey).(*BotTokenInfo) return info, ok } + +func ParseBotToken(rawToken string) (uuid.UUID, string, error) { + tokenIDPart, secret, ok := strings.Cut(rawToken, ".") + if !ok || secret == "" { + return uuid.Nil, "", errors.New("invalid token format") + } + + tokenID, err := uuid.Parse(tokenIDPart) + if err != nil { + return uuid.Nil, "", err + } + + return tokenID, secret, nil +} + +func botTokenExpiry(token database.BotToken) *time.Time { + if !token.ExpiresAt.Valid { + return nil + } + + expiresAt := token.ExpiresAt.Time + return &expiresAt +} diff --git a/internal/router/router.go b/internal/router/router.go index 5895b73..1e2adfd 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -10,6 +10,50 @@ import ( httpSwagger "github.com/swaggo/http-swagger/v2" ) +func mountProtectedRoutes(r chi.Router, h *handler.Handler, jwtSecret string) { + r.Group(func(r chi.Router) { + r.Use(middleware.Auth(jwtSecret)) + + r.Route("/users", func(r chi.Router) { + r.Get("/{uid}", h.GetUser) + r.Put("/{uid}", h.UpdateUser) + r.Delete("/{uid}", h.DeleteUser) + r.Get("/{uid}/organizations", h.GetUserOrganizations) + r.Get("/{uid}/events", h.GetUserEvents) + }) + + r.Route("/organizations", func(r chi.Router) { + r.Get("/", h.ListOrganizations) + r.Post("/", h.CreateOrganization) + r.Get("/{oid}", h.GetOrganization) + r.Put("/{oid}", h.UpdateOrganization) + r.Delete("/{oid}", h.DeleteOrganization) + r.Get("/{oid}/members", h.ListOrgMembers) + r.Post("/{oid}/members", h.AddOrgMember) + r.Delete("/{oid}/members/{uid}", h.RemoveOrgMember) + r.Get("/{oid}/events", h.ListOrgEvents) + }) + + r.Route("/events", func(r chi.Router) { + r.Get("/", h.ListEvents) + r.Post("/", h.CreateEvent) + r.Get("/org/{oid}", h.ListEventsByOrg) + r.Get("/{eid}", h.GetEvent) + r.Put("/{eid}", h.UpdateEvent) + r.Delete("/{eid}", h.DeleteEvent) + r.Get("/{eid}/registrations", h.ListEventRegistrations) + r.Post("/{eid}/register", h.RegisterForEvent) + r.Delete("/{eid}/register", h.UnregisterFromEvent) + }) + + r.Route("/bot/tokens", func(r chi.Router) { + r.Get("/", h.ListBotTokens) + r.Post("/", h.CreateBotToken) + r.Delete("/{token_id}", h.RevokeBotToken) + }) + }) +} + // New creates a new chi router with all routes configured func New(h *handler.Handler, queries database.Querier, jwtSecret string, allowedOrigins []string) chi.Router { r := chi.NewRouter() @@ -50,52 +94,7 @@ func New(h *handler.Handler, queries database.Querier, jwtSecret string, allowed }) }) - // Protected routes - require human authentication - r.Group(func(r chi.Router) { - r.Use(middleware.Auth(jwtSecret)) - - // Users - r.Route("/users", func(r chi.Router) { - r.Get("/{uid}", h.GetUser) - r.Put("/{uid}", h.UpdateUser) - r.Delete("/{uid}", h.DeleteUser) - r.Get("/{uid}/organizations", h.GetUserOrganizations) - r.Get("/{uid}/events", h.GetUserEvents) - }) - - // Organizations - r.Route("/organizations", func(r chi.Router) { - r.Get("/", h.ListOrganizations) - r.Post("/", h.CreateOrganization) - r.Get("/{oid}", h.GetOrganization) - r.Put("/{oid}", h.UpdateOrganization) - r.Delete("/{oid}", h.DeleteOrganization) - r.Get("/{oid}/members", h.ListOrgMembers) - r.Post("/{oid}/members", h.AddOrgMember) - r.Delete("/{oid}/members/{uid}", h.RemoveOrgMember) - r.Get("/{oid}/events", h.ListOrgEvents) - }) - - // Events - r.Route("/events", func(r chi.Router) { - r.Get("/", h.ListEvents) - r.Post("/", h.CreateEvent) - r.Get("/org/{oid}", h.ListEventsByOrg) - r.Get("/{eid}", h.GetEvent) - r.Put("/{eid}", h.UpdateEvent) - r.Delete("/{eid}", h.DeleteEvent) - r.Get("/{eid}/registrations", h.ListEventRegistrations) - r.Post("/{eid}/register", h.RegisterForEvent) - r.Delete("/{eid}/register", h.UnregisterFromEvent) - }) - - // Bot token management (human auth only) - r.Route("/bot/tokens", func(r chi.Router) { - r.Get("/", h.ListBotTokens) - r.Post("/", h.CreateBotToken) - r.Delete("/{token_id}", h.RevokeBotToken) - }) - }) + mountProtectedRoutes(r, h, jwtSecret) // Bot routes (M2M auth) r.Group(func(r chi.Router) { @@ -134,5 +133,9 @@ func New(h *handler.Handler, queries database.Querier, jwtSecret string, allowed }) }) + r.Route("/v1", func(r chi.Router) { + mountProtectedRoutes(r, h, jwtSecret) + }) + return r } diff --git a/internal/router/router_auth_test.go b/internal/router/router_auth_test.go new file mode 100644 index 0000000..4079fa7 --- /dev/null +++ b/internal/router/router_auth_test.go @@ -0,0 +1,279 @@ +package router_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/database/mocks" + "github.com/capyrpi/api/internal/handler" + "github.com/capyrpi/api/internal/middleware" + "github.com/capyrpi/api/internal/router" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestBotTokenLifecycle(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + facultyID := uuid.New() + tokenID := uuid.New() + var storedHash string + + mockQueries.On("CreateBotToken", mock.Anything, mock.MatchedBy(func(arg database.CreateBotTokenParams) bool { + storedHash = arg.TokenHash + return arg.Name == "deploy-bot" && arg.CreatedBy == facultyID && arg.ExpiresAt.Valid + })).Return(database.BotToken{ + TokenID: tokenID, + Name: "deploy-bot", + CreatedAt: pgTimestamp(time.Now().UTC()), + ExpiresAt: pgTimestamp(time.Now().UTC().Add(24 * time.Hour)), + IsActive: pgBool(true), + }, nil).Once() + + createReqBody := []byte(`{"name":"deploy-bot","expires_at":"2030-01-02T03:04:05Z"}`) + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/bot/tokens", bytes.NewReader(createReqBody)) + createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleFaculty))) + createRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(createRes, createReq) + require.Equal(t, http.StatusCreated, createRes.Code) + + var createdTokenResp handler.BotTokenResponse + require.NoError(t, json.Unmarshal(createRes.Body.Bytes(), &createdTokenResp)) + assert.Equal(t, tokenID, createdTokenResp.TokenID) + assert.NotEmpty(t, createdTokenResp.Token) + + mockQueries.On("ListBotTokens", mock.Anything).Return([]database.ListBotTokensRow{ + { + TokenID: tokenID, + Name: "deploy-bot", + CreatedAt: pgTimestamp(time.Now().UTC()), + ExpiresAt: pgTimestamp(time.Now().UTC().Add(24 * time.Hour)), + IsActive: pgBool(true), + }, + }, nil).Once() + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) + listReq.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleFaculty))) + listRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(listRes, listReq) + require.Equal(t, http.StatusOK, listRes.Code) + + var listedTokens []map[string]any + require.NoError(t, json.Unmarshal(listRes.Body.Bytes(), &listedTokens)) + require.Len(t, listedTokens, 1) + _, tokenPresent := listedTokens[0]["token"] + assert.False(t, tokenPresent) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(func(context.Context, uuid.UUID) database.BotToken { + return database.BotToken{ + TokenID: tokenID, + TokenHash: storedHash, + Name: "deploy-bot", + ExpiresAt: pgTimestamp(time.Now().UTC().Add(24 * time.Hour)), + IsActive: pgBool(true), + } + }, nil).Once() + mockQueries.On("UpdateBotTokenLastUsed", mock.Anything, tokenID).Return(nil).Once() + + meReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + meReq.Header.Set("X-Bot-Token", createdTokenResp.Token) + meRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(meRes, meReq) + require.Equal(t, http.StatusOK, meRes.Code) + + var meResp handler.BotMeResponse + require.NoError(t, json.Unmarshal(meRes.Body.Bytes(), &meResp)) + assert.Equal(t, tokenID, meResp.TokenID) + assert.Equal(t, "deploy-bot", meResp.Name) + assert.Equal(t, "bot", meResp.AuthType) + + mockQueries.On("RevokeBotToken", mock.Anything, tokenID).Return(nil).Once() + + revokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/bot/tokens/"+tokenID.String(), nil) + revokeReq.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleFaculty))) + revokeRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(revokeRes, revokeReq) + require.Equal(t, http.StatusNoContent, revokeRes.Code) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(database.BotToken{ + TokenID: tokenID, + TokenHash: storedHash, + Name: "deploy-bot", + IsActive: pgBool(false), + }, nil).Once() + + revokedReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + revokedReq.Header.Set("X-Bot-Token", createdTokenResp.Token) + revokedRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(revokedRes, revokedReq) + assert.Equal(t, http.StatusUnauthorized, revokedRes.Code) +} + +func TestBotRouteAuthBoundaries(t *testing.T) { + t.Run("MissingBotHeaderFails", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("InvalidBotTokenFails", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + tokenID := uuid.New() + hash, err := bcryptHash("correct-secret") + require.NoError(t, err) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(database.BotToken{ + TokenID: tokenID, + TokenHash: hash, + Name: "deploy-bot", + IsActive: pgBool(true), + }, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + req.Header.Set("X-Bot-Token", tokenID.String()+".wrong-secret") + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("ExpiredBotTokenFails", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + tokenID := uuid.New() + hash, err := bcryptHash("secret") + require.NoError(t, err) + + mockQueries.On("GetBotTokenByID", mock.Anything, tokenID).Return(database.BotToken{ + TokenID: tokenID, + TokenHash: hash, + Name: "deploy-bot", + ExpiresAt: pgTimestamp(time.Now().UTC().Add(-time.Minute)), + IsActive: pgBool(true), + }, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + req.Header.Set("X-Bot-Token", tokenID.String()+".secret") + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("HumanEndpointsRejectBotToken", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("X-Bot-Token", uuid.New().String()+".secret") + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) + + t.Run("BotEndpointsRejectHumanJWT", func(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) + req.Header.Set("Authorization", "Bearer "+makeJWT(t, uuid.New(), string(database.UserRoleFaculty))) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) +} + +func TestBotTokenManagementRequiresFaculty(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + userID := uuid.New() + tokenID := uuid.New() + + createReq := httptest.NewRequest(http.MethodPost, "/api/v1/bot/tokens", bytes.NewBufferString(`{"name":"deploy-bot"}`)) + createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) + createRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(createRes, createReq) + assert.Equal(t, http.StatusForbidden, createRes.Code) + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) + listReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) + listRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(listRes, listReq) + assert.Equal(t, http.StatusForbidden, listRes.Code) + + revokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/bot/tokens/"+tokenID.String(), nil) + revokeReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) + revokeRes := httptest.NewRecorder() + routerUnderTest.ServeHTTP(revokeRes, revokeReq) + assert.Equal(t, http.StatusForbidden, revokeRes.Code) +} + +func newTestRouter(queries database.Querier) http.Handler { + cfg := &config.Config{ + Env: "test", + JWT: config.JWTConfig{ + Secret: "test-secret", + ExpiryHours: 24, + }, + } + + h := handler.New(queries, cfg) + return router.New(h, queries, cfg.JWT.Secret, nil) +} + +func makeJWT(t *testing.T, userID uuid.UUID, role string) string { + t.Helper() + + claims := middleware.UserClaims{ + UserID: userID.String(), + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte("test-secret")) + require.NoError(t, err) + return tokenString +} + +func pgBool(v bool) pgtype.Bool { + return pgtype.Bool{Bool: v, Valid: true} +} + +func pgTimestamp(v time.Time) pgtype.Timestamp { + return pgtype.Timestamp{Time: v, Valid: true} +} + +func bcryptHash(secret string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} From f782b7d667577a847e75147087bf5928c301bb91 Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Wed, 18 Mar 2026 10:43:34 -0400 Subject: [PATCH 2/6] feat(tests): added testing for bot routes --- tests/integration/api_test.go | 280 ++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index 45330c7..b826787 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -20,6 +20,7 @@ import ( "github.com/capyrpi/api/internal/router" "github.com/capyrpi/api/internal/testutils" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -696,3 +697,282 @@ func TestAddDuplicateUser(t *testing.T) { schoolUser, err := q.GetUserByEmail(ctx, pgtype.Text{String: "testuser@rpi.edu", Valid: true}) assert.Equal(t, addedUser.Uid, schoolUser.Uid) } + +func TestBotRoutes(t *testing.T) { + pool := testutils.SetupTestDB(t) + defer pool.Close() + + q := database.New(pool) + cfg := &config.Config{JWT: config.JWTConfig{Secret: "test-secret", ExpiryHours: 1}} + h := handler.New(q, cfg) + r := router.New(h, q, cfg.JWT.Secret, []string{}) + server := httptest.NewServer(r) + defer server.Close() + client := server.Client() + + ctx := context.Background() + faculty, err := q.CreateUser(ctx, database.CreateUserParams{ + FirstName: "Bot", + LastName: "Admin", + Role: database.NullUserRole{UserRole: database.UserRoleFaculty, Valid: true}, + }) + require.NoError(t, err) + + member, err := q.CreateUser(ctx, database.CreateUserParams{ + FirstName: "Bot", + LastName: "Member", + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + }) + require.NoError(t, err) + + botToken := createIntegrationBotToken(t, client, server.URL, cfg.JWT.Secret, faculty.Uid) + + meReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/me", nil) + meReq.Header.Set("X-Bot-Token", botToken) + meResp, err := client.Do(meReq) + require.NoError(t, err) + defer meResp.Body.Close() + require.Equal(t, http.StatusOK, meResp.StatusCode) + + var botMe handler.BotMeResponse + require.NoError(t, json.NewDecoder(meResp.Body).Decode(&botMe)) + assert.Equal(t, "bot", botMe.AuthType) + + orgCreateReq, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/bot/organizations", bytes.NewBufferString(`{"name":"Bot Org"}`)) + orgCreateReq.Header.Set("Content-Type", "application/json") + orgCreateReq.Header.Set("X-Bot-Token", botToken) + orgCreateResp, err := client.Do(orgCreateReq) + require.NoError(t, err) + defer orgCreateResp.Body.Close() + require.Equal(t, http.StatusCreated, orgCreateResp.StatusCode) + + var createdOrg dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgCreateResp.Body).Decode(&createdOrg)) + assert.Equal(t, "Bot Org", createdOrg.Name) + + orgListReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/organizations", nil) + orgListReq.Header.Set("X-Bot-Token", botToken) + orgListResp, err := client.Do(orgListReq) + require.NoError(t, err) + defer orgListResp.Body.Close() + require.Equal(t, http.StatusOK, orgListResp.StatusCode) + + var orgs []dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgListResp.Body).Decode(&orgs)) + require.NotEmpty(t, orgs) + + orgGetReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/organizations/%s", server.URL, createdOrg.OID), nil) + orgGetReq.Header.Set("X-Bot-Token", botToken) + orgGetResp, err := client.Do(orgGetReq) + require.NoError(t, err) + defer orgGetResp.Body.Close() + require.Equal(t, http.StatusOK, orgGetResp.StatusCode) + + var fetchedOrg dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgGetResp.Body).Decode(&fetchedOrg)) + assert.Equal(t, createdOrg.OID, fetchedOrg.OID) + + orgUpdateReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/bot/organizations/%s", server.URL, createdOrg.OID), bytes.NewBufferString(`{"name":"Bot Org Updated"}`)) + orgUpdateReq.Header.Set("Content-Type", "application/json") + orgUpdateReq.Header.Set("X-Bot-Token", botToken) + orgUpdateResp, err := client.Do(orgUpdateReq) + require.NoError(t, err) + defer orgUpdateResp.Body.Close() + require.Equal(t, http.StatusOK, orgUpdateResp.StatusCode) + + var updatedOrg dto.OrganizationResponse + require.NoError(t, json.NewDecoder(orgUpdateResp.Body).Decode(&updatedOrg)) + assert.Equal(t, "Bot Org Updated", updatedOrg.Name) + + memberListReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members", server.URL, createdOrg.OID), nil) + memberListReq.Header.Set("X-Bot-Token", botToken) + memberListResp, err := client.Do(memberListReq) + require.NoError(t, err) + defer memberListResp.Body.Close() + require.Equal(t, http.StatusOK, memberListResp.StatusCode) + + var initialMembers []dto.OrgMemberResponse + require.NoError(t, json.NewDecoder(memberListResp.Body).Decode(&initialMembers)) + assert.Len(t, initialMembers, 0) + + addMemberReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members", server.URL, createdOrg.OID), bytes.NewBufferString(fmt.Sprintf(`{"uid":"%s","is_admin":false}`, member.Uid))) + addMemberReq.Header.Set("Content-Type", "application/json") + addMemberReq.Header.Set("X-Bot-Token", botToken) + addMemberResp, err := client.Do(addMemberReq) + require.NoError(t, err) + defer addMemberResp.Body.Close() + require.Equal(t, http.StatusCreated, addMemberResp.StatusCode) + + memberListReq, _ = http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members", server.URL, createdOrg.OID), nil) + memberListReq.Header.Set("X-Bot-Token", botToken) + memberListResp, err = client.Do(memberListReq) + require.NoError(t, err) + defer memberListResp.Body.Close() + require.Equal(t, http.StatusOK, memberListResp.StatusCode) + + var orgMembers []dto.OrgMemberResponse + require.NoError(t, json.NewDecoder(memberListResp.Body).Decode(&orgMembers)) + require.Len(t, orgMembers, 1) + assert.Equal(t, member.Uid, orgMembers[0].UID) + + userReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/users/%s", server.URL, member.Uid), nil) + userReq.Header.Set("X-Bot-Token", botToken) + userResp, err := client.Do(userReq) + require.NoError(t, err) + defer userResp.Body.Close() + require.Equal(t, http.StatusOK, userResp.StatusCode) + + var user dto.UserResponse + require.NoError(t, json.NewDecoder(userResp.Body).Decode(&user)) + assert.Equal(t, member.Uid, user.UID) + + userOrgsReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/users/%s/organizations", server.URL, member.Uid), nil) + userOrgsReq.Header.Set("X-Bot-Token", botToken) + userOrgsResp, err := client.Do(userOrgsReq) + require.NoError(t, err) + defer userOrgsResp.Body.Close() + require.Equal(t, http.StatusOK, userOrgsResp.StatusCode) + + var userOrgs []dto.OrganizationResponse + require.NoError(t, json.NewDecoder(userOrgsResp.Body).Decode(&userOrgs)) + require.Len(t, userOrgs, 1) + assert.Equal(t, createdOrg.OID, userOrgs[0].OID) + + eventTime := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) + eventCreateReq, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/bot/events", bytes.NewBufferString(fmt.Sprintf(`{"org_id":"%s","location":"Bot Hall","description":"Bot Event","event_time":"%s"}`, createdOrg.OID, eventTime))) + eventCreateReq.Header.Set("Content-Type", "application/json") + eventCreateReq.Header.Set("X-Bot-Token", botToken) + eventCreateResp, err := client.Do(eventCreateReq) + require.NoError(t, err) + defer eventCreateResp.Body.Close() + require.Equal(t, http.StatusCreated, eventCreateResp.StatusCode) + + var createdEvent dto.EventResponse + require.NoError(t, json.NewDecoder(eventCreateResp.Body).Decode(&createdEvent)) + require.NotEqual(t, uuid.Nil, createdEvent.EID) + + eventListReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/events", nil) + eventListReq.Header.Set("X-Bot-Token", botToken) + eventListResp, err := client.Do(eventListReq) + require.NoError(t, err) + defer eventListResp.Body.Close() + require.Equal(t, http.StatusOK, eventListResp.StatusCode) + + var events []dto.EventResponse + require.NoError(t, json.NewDecoder(eventListResp.Body).Decode(&events)) + require.NotEmpty(t, events) + + eventGetReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/events/%s", server.URL, createdEvent.EID), nil) + eventGetReq.Header.Set("X-Bot-Token", botToken) + eventGetResp, err := client.Do(eventGetReq) + require.NoError(t, err) + defer eventGetResp.Body.Close() + require.Equal(t, http.StatusOK, eventGetResp.StatusCode) + + var fetchedEvent dto.EventResponse + require.NoError(t, json.NewDecoder(eventGetResp.Body).Decode(&fetchedEvent)) + assert.Equal(t, createdEvent.EID, fetchedEvent.EID) + + eventUpdateReq, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("%s/api/v1/bot/events/%s", server.URL, createdEvent.EID), bytes.NewBufferString(`{"location":"Bot Hall Updated","description":"Updated Bot Event"}`)) + eventUpdateReq.Header.Set("Content-Type", "application/json") + eventUpdateReq.Header.Set("X-Bot-Token", botToken) + eventUpdateResp, err := client.Do(eventUpdateReq) + require.NoError(t, err) + defer eventUpdateResp.Body.Close() + require.Equal(t, http.StatusOK, eventUpdateResp.StatusCode) + + var updatedEvent dto.EventResponse + require.NoError(t, json.NewDecoder(eventUpdateResp.Body).Decode(&updatedEvent)) + require.NotNil(t, updatedEvent.Location) + assert.Equal(t, "Bot Hall Updated", *updatedEvent.Location) + + registerReq, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/v1/bot/events/%s/register", server.URL, createdEvent.EID), bytes.NewBufferString(fmt.Sprintf(`{"uid":"%s","is_attending":true}`, member.Uid))) + registerReq.Header.Set("Content-Type", "application/json") + registerReq.Header.Set("X-Bot-Token", botToken) + registerResp, err := client.Do(registerReq) + require.NoError(t, err) + defer registerResp.Body.Close() + require.Equal(t, http.StatusCreated, registerResp.StatusCode) + + regListReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/events/%s/registrations", server.URL, createdEvent.EID), nil) + regListReq.Header.Set("X-Bot-Token", botToken) + regListResp, err := client.Do(regListReq) + require.NoError(t, err) + defer regListResp.Body.Close() + require.Equal(t, http.StatusOK, regListResp.StatusCode) + + var regs []dto.EventRegistrationResponse + require.NoError(t, json.NewDecoder(regListResp.Body).Decode(®s)) + require.Len(t, regs, 1) + assert.Equal(t, member.Uid, regs[0].UID) + + userEventsReq, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/bot/users/%s/events", server.URL, member.Uid), nil) + userEventsReq.Header.Set("X-Bot-Token", botToken) + userEventsResp, err := client.Do(userEventsReq) + require.NoError(t, err) + defer userEventsResp.Body.Close() + require.Equal(t, http.StatusOK, userEventsResp.StatusCode) + + var userEvents []dto.EventResponse + require.NoError(t, json.NewDecoder(userEventsResp.Body).Decode(&userEvents)) + require.Len(t, userEvents, 1) + assert.Equal(t, createdEvent.EID, userEvents[0].EID) + + unregisterReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/events/%s/register?uid=%s", server.URL, createdEvent.EID, member.Uid), nil) + unregisterReq.Header.Set("X-Bot-Token", botToken) + unregisterResp, err := client.Do(unregisterReq) + require.NoError(t, err) + defer unregisterResp.Body.Close() + require.Equal(t, http.StatusNoContent, unregisterResp.StatusCode) + + removeMemberReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/organizations/%s/members/%s", server.URL, createdOrg.OID, member.Uid), nil) + removeMemberReq.Header.Set("X-Bot-Token", botToken) + removeMemberResp, err := client.Do(removeMemberReq) + require.NoError(t, err) + defer removeMemberResp.Body.Close() + require.Equal(t, http.StatusNoContent, removeMemberResp.StatusCode) + + deleteEventReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/events/%s", server.URL, createdEvent.EID), nil) + deleteEventReq.Header.Set("X-Bot-Token", botToken) + deleteEventResp, err := client.Do(deleteEventReq) + require.NoError(t, err) + defer deleteEventResp.Body.Close() + require.Equal(t, http.StatusNoContent, deleteEventResp.StatusCode) + + deleteOrgReq, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v1/bot/organizations/%s", server.URL, createdOrg.OID), nil) + deleteOrgReq.Header.Set("X-Bot-Token", botToken) + deleteOrgResp, err := client.Do(deleteOrgReq) + require.NoError(t, err) + defer deleteOrgResp.Body.Close() + require.Equal(t, http.StatusNoContent, deleteOrgResp.StatusCode) +} + +func createIntegrationBotToken(t *testing.T, client *http.Client, serverURL, jwtSecret string, facultyID uuid.UUID) string { + t.Helper() + + claims := middleware.UserClaims{ + UserID: facultyID.String(), + Role: string(database.UserRoleFaculty), + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(jwtSecret)) + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodPost, serverURL+"/api/v1/bot/tokens", bytes.NewBufferString(`{"name":"integration-bot"}`)) + req.Header.Set("Authorization", "Bearer "+tokenString) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusCreated, resp.StatusCode) + + var botTokenResp handler.BotTokenResponse + require.NoError(t, json.NewDecoder(resp.Body).Decode(&botTokenResp)) + require.NotEmpty(t, botTokenResp.Token) + + return botTokenResp.Token +} From 3fe11dc339ded98542f11d0a5c865b76755eaa5a Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 19 Mar 2026 10:45:49 -0400 Subject: [PATCH 3/6] feat(faculty): move toward db role over jwt role --- internal/handler/auth.go | 14 +++++++++++- internal/router/router_auth_test.go | 33 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/internal/handler/auth.go b/internal/handler/auth.go index d389a90..42be130 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -641,7 +641,19 @@ func (h *Handler) requireFacultyClaims(w http.ResponseWriter, r *http.Request) ( return nil, false } - if claims.Role != string(database.UserRoleFaculty) { + uid, err := uuid.Parse(claims.UserID) + if err != nil { + h.respondError(w, http.StatusUnauthorized, "Invalid user ID in token") + return nil, false + } + + user, err := h.queries.GetUserByID(r.Context(), uid) + if err != nil { + h.handleDBError(w, err) + return nil, false + } + + if !user.Role.Valid || user.Role.UserRole != database.UserRoleFaculty { h.respondError(w, http.StatusForbidden, "Faculty role required") return nil, false } diff --git a/internal/router/router_auth_test.go b/internal/router/router_auth_test.go index 4079fa7..5a10dab 100644 --- a/internal/router/router_auth_test.go +++ b/internal/router/router_auth_test.go @@ -31,6 +31,12 @@ func TestBotTokenLifecycle(t *testing.T) { facultyID := uuid.New() tokenID := uuid.New() var storedHash string + facultyUser := database.User{ + Uid: facultyID, + Role: database.NullUserRole{UserRole: database.UserRoleFaculty, Valid: true}, + } + + mockQueries.On("GetUserByID", mock.Anything, facultyID).Return(facultyUser, nil).Times(3) mockQueries.On("CreateBotToken", mock.Anything, mock.MatchedBy(func(arg database.CreateBotTokenParams) bool { storedHash = arg.TokenHash @@ -212,6 +218,12 @@ func TestBotTokenManagementRequiresFaculty(t *testing.T) { userID := uuid.New() tokenID := uuid.New() + studentUser := database.User{ + Uid: userID, + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + } + + mockQueries.On("GetUserByID", mock.Anything, userID).Return(studentUser, nil).Times(3) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/bot/tokens", bytes.NewBufferString(`{"name":"deploy-bot"}`)) createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, userID, string(database.UserRoleStudent))) @@ -232,6 +244,27 @@ func TestBotTokenManagementRequiresFaculty(t *testing.T) { assert.Equal(t, http.StatusForbidden, revokeRes.Code) } +func TestBotTokenManagementUsesDatabaseRole(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + routerUnderTest := newTestRouter(mockQueries) + + facultyID := uuid.New() + facultyUser := database.User{ + Uid: facultyID, + Role: database.NullUserRole{UserRole: database.UserRoleFaculty, Valid: true}, + } + + mockQueries.On("GetUserByID", mock.Anything, facultyID).Return(facultyUser, nil).Once() + mockQueries.On("ListBotTokens", mock.Anything).Return([]database.ListBotTokensRow{}, nil).Once() + + req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) + req.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleStudent))) + res := httptest.NewRecorder() + routerUnderTest.ServeHTTP(res, req) + + assert.Equal(t, http.StatusOK, res.Code) +} + func newTestRouter(queries database.Querier) http.Handler { cfg := &config.Config{ Env: "test", From e429a517ce30f4cb0a7e79c799e82c956491f6cb Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Thu, 19 Mar 2026 17:44:03 -0400 Subject: [PATCH 4/6] feat(roles): db is now role source of truth --- README.md | 30 +++- docker-compose.yml | 16 +- internal/database/models.go | 1 + internal/database/queries.sql | 16 +- internal/database/queries.sql.go | 16 +- internal/dto/dto.go | 4 +- internal/handler/auth.go | 50 ++---- internal/handler/auth_test.go | 63 ++++++++ internal/handler/authz.go | 140 +++++++++++++++++ internal/handler/events.go | 87 +++++++++-- internal/handler/handler.go | 5 + internal/handler/organizations.go | 16 ++ internal/handler/users.go | 13 ++ internal/middleware/auth.go | 1 - internal/router/router_auth_test.go | 35 ++--- .../20260318030206_telemetry_table.down.sql | 2 + .../20260318030206_telemetry_table.up.sql | 27 ++++ .../20260319120000_add_dev_user_role.down.sql | 2 + .../20260319120000_add_dev_user_role.up.sql | 1 + schema.sql | 4 +- scripts/create_dev_user/main.go | 83 ---------- scripts/create_user/main.go | 146 ++++++++++++++++++ scripts/generate_token/main.go | 77 --------- tests/benchmarks/suite_test.go | 1 - tests/integration/api_test.go | 11 +- 25 files changed, 584 insertions(+), 263 deletions(-) create mode 100644 internal/handler/auth_test.go create mode 100644 internal/handler/authz.go create mode 100644 migrations/20260318030206_telemetry_table.down.sql create mode 100644 migrations/20260318030206_telemetry_table.up.sql create mode 100644 migrations/20260319120000_add_dev_user_role.down.sql create mode 100644 migrations/20260319120000_add_dev_user_role.up.sql delete mode 100644 scripts/create_dev_user/main.go create mode 100644 scripts/create_user/main.go delete mode 100644 scripts/generate_token/main.go diff --git a/README.md b/README.md index 1ae1f52..a01a645 100644 --- a/README.md +++ b/README.md @@ -124,16 +124,34 @@ make test-all ## Development Scripts Helper scripts are located in the `scripts/` directory. -### Create Development User -Seeds a user into the DB and generates a valid JWT for testing. +### Create User +Seeds or updates a user in the database and prints a JWT for that user. `--email` is required; the other fields have defaults. ```bash -go run scripts/create_dev_user/main.go +go run scripts/create_user/main.go --email dev@example.com --role dev ``` -### Generate Token -Manually generates a JWT for an existing user (by email). +### Run DB-Connected Scripts Without Go in the API Image +If you are running the API and Postgres with Docker Compose, the API container does not include the Go toolchain. To run local Go scripts that need database access, start a one-off Go container on the same Compose network and mount the repository into it. + +Current local network: +```bash +api_default +``` + +Example: +```bash +docker run --rm \ + --network api_default \ + -v "$PWD":/app \ + -w /app \ + --env-file .env \ + golang:1.25 \ + go run scripts/create_user/main.go --email dev@example.com --role dev +``` + +If your Compose project name is different, the network name will usually be `_default`. You can check it with: ```bash -go run scripts/generate_token/main.go +docker network ls ``` ## Project Structure diff --git a/docker-compose.yml b/docker-compose.yml index 5ceb332..6594f6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,14 +25,14 @@ services: db: condition: service_healthy - # tunnel: - # image: cloudflare/cloudflared:latest - # restart: unless-stopped - # command: tunnel run - # env_file: - # - .env - # depends_on: - # - api + tunnel: + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel run + env_file: + - .env + depends_on: + - api volumes: pgdata: diff --git a/internal/database/models.go b/internal/database/models.go index a54ed5a..16ba637 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -19,6 +19,7 @@ const ( UserRoleAlumni UserRole = "alumni" UserRoleFaculty UserRole = "faculty" UserRoleExternal UserRole = "external" + UserRoleDev UserRole = "dev" ) func (e *UserRole) Scan(src interface{}) error { diff --git a/internal/database/queries.sql b/internal/database/queries.sql index 6c9cf49..3344cfd 100644 --- a/internal/database/queries.sql +++ b/internal/database/queries.sql @@ -123,7 +123,21 @@ ON CONFLICT (uid, eid) DO UPDATE SET is_attending = $3; DELETE FROM event_registrations WHERE uid = $1 AND eid = $2; -- name: IsEventAdmin :one -SELECT is_admin FROM event_registrations WHERE uid = $1 AND eid = $2; +SELECT EXISTS ( + SELECT 1 + FROM event_registrations er + WHERE er.uid = $1 + AND er.eid = $2 + AND er.is_admin = TRUE +) +OR EXISTS ( + SELECT 1 + FROM event_hosting eh + JOIN org_members om ON om.oid = eh.oid + WHERE eh.eid = $2 + AND om.uid = $1 + AND om.is_admin = TRUE +); -- name: GetUserEvents :many SELECT e.*, er.is_attending, er.is_admin, er.date_registered diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 10d77b2..d495a7e 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -503,7 +503,21 @@ func (q *Queries) GetUserOrganizations(ctx context.Context, uid uuid.UUID) ([]Ge } const isEventAdmin = `-- name: IsEventAdmin :one -SELECT is_admin FROM event_registrations WHERE uid = $1 AND eid = $2 +SELECT EXISTS ( + SELECT 1 + FROM event_registrations er + WHERE er.uid = $1 + AND er.eid = $2 + AND er.is_admin = TRUE +) +OR EXISTS ( + SELECT 1 + FROM event_hosting eh + JOIN org_members om ON om.oid = eh.oid + WHERE eh.eid = $2 + AND om.uid = $1 + AND om.is_admin = TRUE +) ` type IsEventAdminParams struct { diff --git a/internal/dto/dto.go b/internal/dto/dto.go index cabfa00..692c076 100644 --- a/internal/dto/dto.go +++ b/internal/dto/dto.go @@ -17,7 +17,7 @@ type CreateUserRequest struct { SchoolEmail string `json:"school_email,omitempty" validate:"omitempty,email"` Phone string `json:"phone,omitempty"` GradYear int `json:"grad_year,omitempty" validate:"omitempty,gte=2000,lte=2100"` - Role string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external"` + Role string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external dev"` } type UpdateUserRequest struct { @@ -27,7 +27,7 @@ type UpdateUserRequest struct { SchoolEmail *string `json:"school_email,omitempty" validate:"omitempty,email"` Phone *string `json:"phone,omitempty"` GradYear *int `json:"grad_year,omitempty" validate:"omitempty,gte=2000,lte=2100"` - Role *string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external dev"` } type UserResponse struct { diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 42be130..f44a48f 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -311,7 +311,7 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { // ListBotTokens lists all bot tokens // @Summary List bot tokens -// @Description Returns all bot tokens (requires faculty role) +// @Description Returns all bot tokens (requires dev role) // @Tags bot // @Accept json // @Produce json @@ -320,7 +320,7 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens [get] func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { - if !h.requireFaculty(w, r) { + if !h.requireDev(w, r) { return } @@ -346,7 +346,7 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { // CreateBotToken creates a new bot token // @Summary Create bot token -// @Description Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller. +// @Description Creates a new bot token (requires dev role). The raw token is returned only once and must be stored by the caller. // @Tags bot // @Accept json // @Produce json @@ -357,7 +357,7 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens [post] func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { - claims, ok := h.requireFacultyClaims(w, r) + claims, ok := h.requireDevClaims(w, r) if !ok { return } @@ -412,7 +412,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { // RevokeBotToken revokes a bot token // @Summary Revoke bot token -// @Description Revokes a bot token (requires faculty role) +// @Description Revokes a bot token (requires dev role) // @Tags bot // @Accept json // @Produce json @@ -423,7 +423,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { // @Security CookieAuth // @Router /bot/tokens/{token_id} [delete] func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) { - if !h.requireFaculty(w, r) { + if !h.requireDev(w, r) { return } @@ -475,7 +475,6 @@ func (h *Handler) generateJWT(user database.User) (string, error) { claims := &middleware.UserClaims{ UserID: user.Uid.String(), Email: getEmail(user), - Role: string(user.Role.UserRole), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(h.Config.JWT.ExpiryHours) * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -585,7 +584,8 @@ func (h *Handler) verifyStateCookie(w http.ResponseWriter, r *http.Request, stat } func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName string) (database.User, error) { - pgEmail := toPgTextFromString(email) + normalizedEmail := normalizeEmail(email) + pgEmail := toPgTextFromString(normalizedEmail) // Check if user exists user, err := h.queries.GetUserByEmail(ctx, pgEmail) @@ -603,7 +603,7 @@ func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName str LastName: lastName, PersonalEmail: pgEmail, // Default to personal email for oauth SchoolEmail: pgtype.Text{Valid: false}, - Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, // Default role + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, }) } @@ -628,35 +628,3 @@ func generateSecureToken(length int) (string, error) { func formatBotToken(tokenID uuid.UUID, secret string) string { return tokenID.String() + "." + secret } - -func (h *Handler) requireFaculty(w http.ResponseWriter, r *http.Request) bool { - _, ok := h.requireFacultyClaims(w, r) - return ok -} - -func (h *Handler) requireFacultyClaims(w http.ResponseWriter, r *http.Request) (*middleware.UserClaims, bool) { - claims, ok := middleware.GetUserClaims(r.Context()) - if !ok { - h.respondError(w, http.StatusUnauthorized, "Not authenticated") - return nil, false - } - - uid, err := uuid.Parse(claims.UserID) - if err != nil { - h.respondError(w, http.StatusUnauthorized, "Invalid user ID in token") - return nil, false - } - - user, err := h.queries.GetUserByID(r.Context(), uid) - if err != nil { - h.handleDBError(w, err) - return nil, false - } - - if !user.Role.Valid || user.Role.UserRole != database.UserRoleFaculty { - h.respondError(w, http.StatusForbidden, "Faculty role required") - return nil, false - } - - return claims, true -} diff --git a/internal/handler/auth_test.go b/internal/handler/auth_test.go new file mode 100644 index 0000000..fd06d2f --- /dev/null +++ b/internal/handler/auth_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "testing" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/database/mocks" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUpsertUserCreatesStudentByDefault(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + h := New(mockQueries, &config.Config{Env: "development"}) + + mockQueries.On("GetUserByEmail", mock.Anything, pgtype.Text{ + String: "student@example.com", + Valid: true, + }).Return(database.User{}, pgx.ErrNoRows).Once() + mockQueries.On("CreateUser", mock.Anything, mock.MatchedBy(func(arg database.CreateUserParams) bool { + return arg.FirstName == "Grace" && + arg.LastName == "Hopper" && + arg.PersonalEmail.Valid && + arg.PersonalEmail.String == "student@example.com" && + arg.Role.Valid && + arg.Role.UserRole == database.UserRoleStudent + })).Return(database.User{ + Uid: uuid.New(), + PersonalEmail: pgtype.Text{String: "student@example.com", Valid: true}, + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + }, nil).Once() + + user, err := h.upsertUser(context.Background(), " Student@Example.com ", "Grace", "Hopper") + + assert.NoError(t, err) + assert.Equal(t, database.UserRoleStudent, user.Role.UserRole) +} + +func TestUpsertUserReturnsExistingUserWithoutRolePromotion(t *testing.T) { + mockQueries := mocks.NewQuerier(t) + h := New(mockQueries, &config.Config{Env: "development"}) + userID := uuid.New() + + mockQueries.On("GetUserByEmail", mock.Anything, pgtype.Text{ + String: "dev@example.com", + Valid: true, + }).Return(database.User{ + Uid: userID, + PersonalEmail: pgtype.Text{String: "dev@example.com", Valid: true}, + Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, + }, nil).Once() + + user, err := h.upsertUser(context.Background(), " dev@example.com ", "Katherine", "Johnson") + + assert.NoError(t, err) + assert.Equal(t, userID, user.Uid) + assert.Equal(t, database.UserRoleStudent, user.Role.UserRole) +} diff --git a/internal/handler/authz.go b/internal/handler/authz.go new file mode 100644 index 0000000..881396d --- /dev/null +++ b/internal/handler/authz.go @@ -0,0 +1,140 @@ +package handler + +import ( + "net/http" + + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/middleware" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (h *Handler) requireAuthenticatedUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, *middleware.UserClaims, bool) { + claims, ok := middleware.GetUserClaims(r.Context()) + if !ok { + h.respondError(w, http.StatusUnauthorized, "Not authenticated") + return uuid.Nil, nil, false + } + + uid, err := uuid.Parse(claims.UserID) + if err != nil { + h.respondError(w, http.StatusUnauthorized, "Invalid user ID in token") + return uuid.Nil, nil, false + } + + return uid, claims, true +} + +func (h *Handler) requireAuthenticatedUserRecord(w http.ResponseWriter, r *http.Request) (database.User, *middleware.UserClaims, bool) { + uid, claims, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return database.User{}, nil, false + } + + user, err := h.queries.GetUserByID(r.Context(), uid) + if err != nil { + h.handleDBError(w, err) + return database.User{}, nil, false + } + + return user, claims, true +} + +func (h *Handler) requireDev(w http.ResponseWriter, r *http.Request) bool { + _, ok := h.requireDevClaims(w, r) + return ok +} + +func (h *Handler) requireDevClaims(w http.ResponseWriter, r *http.Request) (*middleware.UserClaims, bool) { + user, claims, ok := h.requireAuthenticatedUserRecord(w, r) + if !ok { + return nil, false + } + + if !user.Role.Valid || user.Role.UserRole != database.UserRoleDev { + h.respondError(w, http.StatusForbidden, "Dev role required") + return nil, false + } + + return claims, true +} + +func (h *Handler) requireSelfOrDev(w http.ResponseWriter, r *http.Request, targetUID uuid.UUID) (database.User, bool) { + user, _, ok := h.requireAuthenticatedUserRecord(w, r) + if !ok { + return database.User{}, false + } + + if user.Uid == targetUID { + return user, true + } + + if user.Role.Valid && user.Role.UserRole == database.UserRoleDev { + return user, true + } + + h.respondError(w, http.StatusForbidden, "Insufficient permissions") + return database.User{}, false +} + +func (h *Handler) requireOrgAdmin(w http.ResponseWriter, r *http.Request, oid uuid.UUID) (uuid.UUID, bool) { + if middleware.GetAuthType(r.Context()) == "bot" { + return uuid.Nil, true + } + + uid, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return uuid.Nil, false + } + + isAdmin, err := h.queries.IsOrgAdmin(r.Context(), database.IsOrgAdminParams{ + Uid: uid, + Oid: oid, + }) + if err != nil { + if err == pgx.ErrNoRows { + h.respondError(w, http.StatusForbidden, "Organization admin required") + return uuid.Nil, false + } + h.handleDBError(w, err) + return uuid.Nil, false + } + + if !isAdmin.Valid || !isAdmin.Bool { + h.respondError(w, http.StatusForbidden, "Organization admin required") + return uuid.Nil, false + } + + return uid, true +} + +func (h *Handler) requireEventAdmin(w http.ResponseWriter, r *http.Request, eid uuid.UUID) (uuid.UUID, bool) { + if middleware.GetAuthType(r.Context()) == "bot" { + return uuid.Nil, true + } + + uid, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return uuid.Nil, false + } + + isAdmin, err := h.queries.IsEventAdmin(r.Context(), database.IsEventAdminParams{ + Uid: uid, + Eid: eid, + }) + if err != nil { + if err == pgx.ErrNoRows { + h.respondError(w, http.StatusForbidden, "Event admin required") + return uuid.Nil, false + } + h.handleDBError(w, err) + return uuid.Nil, false + } + + if !isAdmin.Valid || !isAdmin.Bool { + h.respondError(w, http.StatusForbidden, "Event admin required") + return uuid.Nil, false + } + + return uid, true +} diff --git a/internal/handler/events.go b/internal/handler/events.go index 0d247f7..81c58ef 100644 --- a/internal/handler/events.go +++ b/internal/handler/events.go @@ -6,6 +6,7 @@ import ( "github.com/capyrpi/api/internal/database" "github.com/capyrpi/api/internal/dto" + "github.com/capyrpi/api/internal/middleware" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -66,6 +67,10 @@ func (h *Handler) CreateEvent(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, req.OrgID); !ok { + return + } + event, err := h.queries.CreateEvent(r.Context(), database.CreateEventParams{ Location: toPgTextFromString(req.Location), EventTime: toPgTimestamp(req.EventTime), @@ -138,6 +143,10 @@ func (h *Handler) UpdateEvent(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + var req dto.UpdateEventRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -178,6 +187,10 @@ func (h *Handler) DeleteEvent(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + if err := h.queries.DeleteEvent(r.Context(), eid); err != nil { h.handleDBError(w, err) return @@ -254,14 +267,34 @@ func (h *Handler) RegisterForEvent(w http.ResponseWriter, r *http.Request) { return } - // TODO: Get user ID from context if not provided (for human auth) - if req.UID == nil { - h.respondError(w, http.StatusBadRequest, "uid is required") - return + var targetUID uuid.UUID + switch middleware.GetAuthType(r.Context()) { + case "bot": + if req.UID == nil { + h.respondError(w, http.StatusBadRequest, "uid is required") + return + } + targetUID = *req.UID + default: + authenticatedUID, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return + } + if req.UID == nil { + h.respondError(w, http.StatusBadRequest, "uid is required") + return + } + + targetUID = *req.UID + if targetUID != authenticatedUID { + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + } } if err := h.queries.RegisterForEvent(r.Context(), database.RegisterForEventParams{ - Uid: *req.UID, + Uid: targetUID, Eid: eid, IsAttending: pgtype.Bool{Bool: req.IsAttending, Valid: true}, }); err != nil { @@ -293,18 +326,40 @@ func (h *Handler) UnregisterFromEvent(w http.ResponseWriter, r *http.Request) { return } - // Get UID from query param or context - uidStr := r.URL.Query().Get("uid") - if uidStr == "" { - // TODO: Get from auth context - h.respondError(w, http.StatusBadRequest, "uid is required") - return - } + var uid uuid.UUID + switch middleware.GetAuthType(r.Context()) { + case "bot": + uidStr := r.URL.Query().Get("uid") + if uidStr == "" { + h.respondError(w, http.StatusBadRequest, "uid is required") + return + } - uid, err := uuid.Parse(uidStr) - if err != nil { - h.respondError(w, http.StatusBadRequest, "Invalid user ID") - return + uid, err = uuid.Parse(uidStr) + if err != nil { + h.respondError(w, http.StatusBadRequest, "Invalid user ID") + return + } + default: + authenticatedUID, _, ok := h.requireAuthenticatedUser(w, r) + if !ok { + return + } + + uid = authenticatedUID + if uidStr := r.URL.Query().Get("uid"); uidStr != "" { + targetUID, err := uuid.Parse(uidStr) + if err != nil { + h.respondError(w, http.StatusBadRequest, "Invalid user ID") + return + } + if targetUID != authenticatedUID { + if _, ok := h.requireEventAdmin(w, r, eid); !ok { + return + } + } + uid = targetUID + } } if err := h.queries.UnregisterFromEvent(r.Context(), database.UnregisterFromEventParams{ diff --git a/internal/handler/handler.go b/internal/handler/handler.go index cbfc2e7..66c2447 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -4,6 +4,7 @@ import ( "encoding/json" "log/slog" "net/http" + "strings" "github.com/capyrpi/api/internal/config" "github.com/capyrpi/api/internal/database" @@ -30,6 +31,10 @@ func New(queries database.Querier, cfg *config.Config) *Handler { } } +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + // ErrorResponse represents an API error type ErrorResponse struct { Error string `json:"error"` diff --git a/internal/handler/organizations.go b/internal/handler/organizations.go index c009ace..28f6eaa 100644 --- a/internal/handler/organizations.go +++ b/internal/handler/organizations.go @@ -143,6 +143,10 @@ func (h *Handler) UpdateOrganization(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + var req dto.UpdateOrganizationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -181,6 +185,10 @@ func (h *Handler) DeleteOrganization(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + if err := h.queries.DeleteOrganization(r.Context(), oid); err != nil { h.handleDBError(w, err) return @@ -251,6 +259,10 @@ func (h *Handler) AddOrgMember(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + var req dto.AddMemberRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -290,6 +302,10 @@ func (h *Handler) RemoveOrgMember(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireOrgAdmin(w, r, oid); !ok { + return + } + uidStr := chi.URLParam(r, "uid") uid, err := uuid.Parse(uidStr) if err != nil { diff --git a/internal/handler/users.go b/internal/handler/users.go index c1c9b26..340a592 100644 --- a/internal/handler/users.go +++ b/internal/handler/users.go @@ -60,6 +60,11 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { return } + authenticatedUser, ok := h.requireSelfOrDev(w, r, uid) + if !ok { + return + } + var req dto.UpdateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { h.respondError(w, http.StatusBadRequest, "Invalid request body") @@ -68,6 +73,10 @@ func (h *Handler) UpdateUser(w http.ResponseWriter, r *http.Request) { var role database.NullUserRole if req.Role != nil { + if !authenticatedUser.Role.Valid || authenticatedUser.Role.UserRole != database.UserRoleDev { + h.respondError(w, http.StatusForbidden, "Only dev may update user roles") + return + } role = database.NullUserRole{UserRole: database.UserRole(*req.Role), Valid: true} } @@ -109,6 +118,10 @@ func (h *Handler) DeleteUser(w http.ResponseWriter, r *http.Request) { return } + if _, ok := h.requireSelfOrDev(w, r, uid); !ok { + return + } + if err := h.queries.DeleteUser(r.Context(), uid); err != nil { h.handleDBError(w, err) return diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1503c27..84024a2 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -19,7 +19,6 @@ const ( type UserClaims struct { UserID string `json:"uid"` Email string `json:"email"` - Role string `json:"role"` jwt.RegisteredClaims } diff --git a/internal/router/router_auth_test.go b/internal/router/router_auth_test.go index 5a10dab..43a359d 100644 --- a/internal/router/router_auth_test.go +++ b/internal/router/router_auth_test.go @@ -28,19 +28,19 @@ func TestBotTokenLifecycle(t *testing.T) { mockQueries := mocks.NewQuerier(t) routerUnderTest := newTestRouter(mockQueries) - facultyID := uuid.New() + devID := uuid.New() tokenID := uuid.New() var storedHash string - facultyUser := database.User{ - Uid: facultyID, - Role: database.NullUserRole{UserRole: database.UserRoleFaculty, Valid: true}, + devUser := database.User{ + Uid: devID, + Role: database.NullUserRole{UserRole: database.UserRoleDev, Valid: true}, } - mockQueries.On("GetUserByID", mock.Anything, facultyID).Return(facultyUser, nil).Times(3) + mockQueries.On("GetUserByID", mock.Anything, devID).Return(devUser, nil).Times(3) mockQueries.On("CreateBotToken", mock.Anything, mock.MatchedBy(func(arg database.CreateBotTokenParams) bool { storedHash = arg.TokenHash - return arg.Name == "deploy-bot" && arg.CreatedBy == facultyID && arg.ExpiresAt.Valid + return arg.Name == "deploy-bot" && arg.CreatedBy == devID && arg.ExpiresAt.Valid })).Return(database.BotToken{ TokenID: tokenID, Name: "deploy-bot", @@ -51,7 +51,7 @@ func TestBotTokenLifecycle(t *testing.T) { createReqBody := []byte(`{"name":"deploy-bot","expires_at":"2030-01-02T03:04:05Z"}`) createReq := httptest.NewRequest(http.MethodPost, "/api/v1/bot/tokens", bytes.NewReader(createReqBody)) - createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleFaculty))) + createReq.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleDev))) createRes := httptest.NewRecorder() routerUnderTest.ServeHTTP(createRes, createReq) require.Equal(t, http.StatusCreated, createRes.Code) @@ -72,7 +72,7 @@ func TestBotTokenLifecycle(t *testing.T) { }, nil).Once() listReq := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) - listReq.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleFaculty))) + listReq.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleDev))) listRes := httptest.NewRecorder() routerUnderTest.ServeHTTP(listRes, listReq) require.Equal(t, http.StatusOK, listRes.Code) @@ -109,7 +109,7 @@ func TestBotTokenLifecycle(t *testing.T) { mockQueries.On("RevokeBotToken", mock.Anything, tokenID).Return(nil).Once() revokeReq := httptest.NewRequest(http.MethodDelete, "/api/v1/bot/tokens/"+tokenID.String(), nil) - revokeReq.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleFaculty))) + revokeReq.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleDev))) revokeRes := httptest.NewRecorder() routerUnderTest.ServeHTTP(revokeRes, revokeReq) require.Equal(t, http.StatusNoContent, revokeRes.Code) @@ -204,7 +204,7 @@ func TestBotRouteAuthBoundaries(t *testing.T) { routerUnderTest := newTestRouter(mockQueries) req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/me", nil) - req.Header.Set("Authorization", "Bearer "+makeJWT(t, uuid.New(), string(database.UserRoleFaculty))) + req.Header.Set("Authorization", "Bearer "+makeJWT(t, uuid.New(), string(database.UserRoleDev))) res := httptest.NewRecorder() routerUnderTest.ServeHTTP(res, req) @@ -212,7 +212,7 @@ func TestBotRouteAuthBoundaries(t *testing.T) { }) } -func TestBotTokenManagementRequiresFaculty(t *testing.T) { +func TestBotTokenManagementRequiresDev(t *testing.T) { mockQueries := mocks.NewQuerier(t) routerUnderTest := newTestRouter(mockQueries) @@ -248,17 +248,17 @@ func TestBotTokenManagementUsesDatabaseRole(t *testing.T) { mockQueries := mocks.NewQuerier(t) routerUnderTest := newTestRouter(mockQueries) - facultyID := uuid.New() - facultyUser := database.User{ - Uid: facultyID, - Role: database.NullUserRole{UserRole: database.UserRoleFaculty, Valid: true}, + devID := uuid.New() + devUser := database.User{ + Uid: devID, + Role: database.NullUserRole{UserRole: database.UserRoleDev, Valid: true}, } - mockQueries.On("GetUserByID", mock.Anything, facultyID).Return(facultyUser, nil).Once() + mockQueries.On("GetUserByID", mock.Anything, devID).Return(devUser, nil).Once() mockQueries.On("ListBotTokens", mock.Anything).Return([]database.ListBotTokensRow{}, nil).Once() req := httptest.NewRequest(http.MethodGet, "/api/v1/bot/tokens", nil) - req.Header.Set("Authorization", "Bearer "+makeJWT(t, facultyID, string(database.UserRoleStudent))) + req.Header.Set("Authorization", "Bearer "+makeJWT(t, devID, string(database.UserRoleStudent))) res := httptest.NewRecorder() routerUnderTest.ServeHTTP(res, req) @@ -283,7 +283,6 @@ func makeJWT(t *testing.T, userID uuid.UUID, role string) string { claims := middleware.UserClaims{ UserID: userID.String(), - Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, diff --git a/migrations/20260318030206_telemetry_table.down.sql b/migrations/20260318030206_telemetry_table.down.sql new file mode 100644 index 0000000..dc9e460 --- /dev/null +++ b/migrations/20260318030206_telemetry_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS telemetry_interactions; +DROP TABLE IF EXISTS telemetry_completions; diff --git a/migrations/20260318030206_telemetry_table.up.sql b/migrations/20260318030206_telemetry_table.up.sql new file mode 100644 index 0000000..da7778b --- /dev/null +++ b/migrations/20260318030206_telemetry_table.up.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS telemetry_interactions ( + id BIGSERIAL PRIMARY KEY, + correlation_id VARCHAR(12) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + interaction_type VARCHAR(20) NOT NULL, + user_id BIGINT NOT NULL, + command_name VARCHAR(100), + guild_id BIGINT, + guild_name VARCHAR(100), + channel_id BIGINT NOT NULL, + options JSONB NOT NULL DEFAULT '{}'::jsonb, + bot_version VARCHAR(20) NOT NULL DEFAULT 'unknown' +); + +CREATE TABLE IF NOT EXISTS telemetry_completions ( + id BIGSERIAL PRIMARY KEY, + correlation_id VARCHAR(12) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + command_name VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL, + duration_ms NUMERIC(10,2), + error_type VARCHAR(100), + CONSTRAINT telemetry_completions_status_check + CHECK (status IN ('success', 'user_error', 'internal_error')) +); diff --git a/migrations/20260319120000_add_dev_user_role.down.sql b/migrations/20260319120000_add_dev_user_role.down.sql new file mode 100644 index 0000000..6b4d569 --- /dev/null +++ b/migrations/20260319120000_add_dev_user_role.down.sql @@ -0,0 +1,2 @@ +-- PostgreSQL enums do not support dropping a value directly. +-- No-op down migration. diff --git a/migrations/20260319120000_add_dev_user_role.up.sql b/migrations/20260319120000_add_dev_user_role.up.sql new file mode 100644 index 0000000..6dc0c62 --- /dev/null +++ b/migrations/20260319120000_add_dev_user_role.up.sql @@ -0,0 +1 @@ +ALTER TYPE user_role ADD VALUE IF NOT EXISTS 'dev'; diff --git a/schema.sql b/schema.sql index 160c511..d67c444 100644 --- a/schema.sql +++ b/schema.sql @@ -2,7 +2,7 @@ -- Database Schema for CAPY (Club Assistant in Python) -- 1. ENUMs & Functions -CREATE TYPE user_role AS ENUM ('student', 'alumni', 'faculty', 'external'); +CREATE TYPE user_role AS ENUM ('student', 'alumni', 'faculty', 'external', 'dev'); CREATE OR REPLACE FUNCTION update_modified_column() RETURNS TRIGGER AS $$ @@ -88,4 +88,4 @@ DROP TRIGGER IF EXISTS update_orgs_modtime ON organizations; CREATE TRIGGER update_orgs_modtime BEFORE UPDATE ON organizations FOR EACH ROW EXECUTE FUNCTION update_modified_column(); DROP TRIGGER IF EXISTS update_events_modtime ON events; -CREATE TRIGGER update_events_modtime BEFORE UPDATE ON events FOR EACH ROW EXECUTE FUNCTION update_modified_column(); \ No newline at end of file +CREATE TRIGGER update_events_modtime BEFORE UPDATE ON events FOR EACH ROW EXECUTE FUNCTION update_modified_column(); diff --git a/scripts/create_dev_user/main.go b/scripts/create_dev_user/main.go deleted file mode 100644 index 1fef6fc..0000000 --- a/scripts/create_dev_user/main.go +++ /dev/null @@ -1,83 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "log" - "time" - - "github.com/capyrpi/api/internal/config" - "github.com/capyrpi/api/internal/database" - "github.com/capyrpi/api/internal/middleware" - "github.com/golang-jwt/jwt/v5" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/joho/godotenv" -) - -func main() { - envFile := flag.String("env", ".env", "Path to .env file") - flag.Parse() - - if err := godotenv.Load(*envFile); err != nil { - log.Printf("Warning: Error loading .env file: %v", err) - } - - cfg, err := config.Load() - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - // Connect to DB - pool, err := pgxpool.New(context.Background(), cfg.Database.URL) - if err != nil { - log.Fatalf("Unable to connect to database: %v", err) - } - defer pool.Close() - - queries := database.New(pool) - ctx := context.Background() - - // Create User - // Note: UID is generated by DB - user, err := queries.CreateUser(ctx, database.CreateUserParams{ - FirstName: "Dev", - LastName: "User", - PersonalEmail: pgtype.Text{String: "dev@example.com", Valid: true}, - Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, - }) - if err != nil { - // Try to find existing if duplicate - log.Printf("User creation failed (likely exists), finding by email...") - user, err = queries.GetUserByEmail(ctx, pgtype.Text{String: "dev@example.com", Valid: true}) - if err != nil { - log.Fatalf("Failed to create or find user: %v", err) - } - } - - // Create Token - claims := middleware.UserClaims{ - UserID: user.Uid.String(), - Email: user.PersonalEmail.String, - Role: "student", - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(cfg.JWT.Secret)) - if err != nil { - log.Fatalf("Failed to sign token: %v", err) - } - - fmt.Println("\n✅ User Seeded & Token Generated!") - fmt.Println("---------------------------------------------------") - fmt.Printf("User Name: %s %s\n", user.FirstName, user.LastName) - fmt.Printf("User ID: %s\n", user.Uid) - fmt.Println("---------------------------------------------------") - fmt.Println("\nTry this command now:") - fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/v1/auth/me\n", tokenString) -} diff --git a/scripts/create_user/main.go b/scripts/create_user/main.go new file mode 100644 index 0000000..f6146e3 --- /dev/null +++ b/scripts/create_user/main.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "strings" + "time" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/capyrpi/api/internal/middleware" + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" +) + +func main() { + envFile := flag.String("env", ".env", "Path to .env file") + email := flag.String("email", "", "User email (required)") + firstName := flag.String("first-name", "Dev", "User first name") + lastName := flag.String("last-name", "User", "User last name") + role := flag.String("role", string(database.UserRoleStudent), "User role") + hours := flag.Int("hours", 24, "Token validity in hours") + flag.Parse() + + normalizedEmail := normalizeEmail(*email) + if normalizedEmail == "" { + log.Fatal("email is required") + } + + userRole, err := parseRole(*role) + if err != nil { + log.Fatal(err) + } + + if err := godotenv.Load(*envFile); err != nil { + log.Printf("Warning: Error loading .env file: %v", err) + } + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + pool, err := pgxpool.New(context.Background(), cfg.Database.URL) + if err != nil { + log.Fatalf("Unable to connect to database: %v", err) + } + defer pool.Close() + + queries := database.New(pool) + ctx := context.Background() + emailText := pgtype.Text{String: normalizedEmail, Valid: true} + + user, err := queries.GetUserByEmail(ctx, emailText) + if err != nil { + if err != pgx.ErrNoRows { + log.Fatalf("Failed to look up user: %v", err) + } + + user, err = queries.CreateUser(ctx, database.CreateUserParams{ + FirstName: *firstName, + LastName: *lastName, + PersonalEmail: emailText, + Role: database.NullUserRole{UserRole: userRole, Valid: true}, + }) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + + printResult("created", user, mustMintToken(user, cfg, *hours)) + return + } + + user, err = queries.UpdateUser(ctx, database.UpdateUserParams{ + Uid: user.Uid, + FirstName: pgtype.Text{String: *firstName, Valid: true}, + LastName: pgtype.Text{String: *lastName, Valid: true}, + PersonalEmail: emailText, + SchoolEmail: pgtype.Text{Valid: false}, + Phone: pgtype.Text{Valid: false}, + GradYear: pgtype.Int4{Valid: false}, + Role: database.NullUserRole{UserRole: userRole, Valid: true}, + }) + if err != nil { + log.Fatalf("Failed to update user: %v", err) + } + + printResult("updated", user, mustMintToken(user, cfg, *hours)) +} + +func normalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func parseRole(raw string) (database.UserRole, error) { + role := database.UserRole(strings.ToLower(strings.TrimSpace(raw))) + + switch role { + case database.UserRoleStudent, database.UserRoleAlumni, database.UserRoleFaculty, database.UserRoleExternal, database.UserRoleDev: + return role, nil + default: + return "", fmt.Errorf("invalid role %q", raw) + } +} + +func mustMintToken(user database.User, cfg *config.Config, hours int) string { + claims := middleware.UserClaims{ + UserID: user.Uid.String(), + Email: user.PersonalEmail.String, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(hours) * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: "capy-api", + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(cfg.JWT.Secret)) + if err != nil { + log.Fatalf("Failed to sign token: %v", err) + } + return tokenString +} + +func printResult(action string, user database.User, token string) { + fmt.Printf("\nUser %s successfully.\n", action) + fmt.Println("---------------------------------------------------") + fmt.Printf("User Name: %s %s\n", user.FirstName, user.LastName) + fmt.Printf("User ID: %s\n", user.Uid) + if user.PersonalEmail.Valid { + fmt.Printf("Email: %s\n", user.PersonalEmail.String) + } + if user.Role.Valid { + fmt.Printf("Role: %s\n", user.Role.UserRole) + } + fmt.Println("---------------------------------------------------") + fmt.Println("\nUsage with curl:") + fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/v1/auth/me\n", token) + fmt.Println("\nToken:") + fmt.Println(token) +} diff --git a/scripts/generate_token/main.go b/scripts/generate_token/main.go deleted file mode 100644 index f65c859..0000000 --- a/scripts/generate_token/main.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "time" - - "github.com/capyrpi/api/internal/config" - "github.com/capyrpi/api/internal/middleware" - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/joho/godotenv" -) - -func main() { - // flags - envFile := flag.String("env", ".env", "Path to .env file") - uid := flag.String("uid", "", "User ID (UUID). If empty, generates a random one.") - email := flag.String("email", "dev@example.com", "User email") - role := flag.String("role", "student", "User role") - hours := flag.Int("hours", 24, "Token validity in hours") - flag.Parse() - - // Load .env if present - if err := godotenv.Load(*envFile); err != nil { - log.Printf("Warning: Error loading .env file: %v. Assuming environment variables are set.", err) - } - - // Load config to get secret - cfg, err := config.Load() - if err != nil { - log.Fatalf("Failed to load config: %v", err) - } - - // Determine UID - var userID string - if *uid == "" { - userID = uuid.New().String() - fmt.Printf("Generated random User ID: %s\n", userID) - } else { - userID = *uid - // Validate UUID - if _, err := uuid.Parse(userID); err != nil { - log.Fatalf("Invalid UUID format: %v", err) - } - } - - // Create Claims - claims := middleware.UserClaims{ - UserID: userID, - Email: *email, - Role: *role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(*hours) * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - }, - } - - // Sign Token - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - tokenString, err := token.SignedString([]byte(cfg.JWT.Secret)) - if err != nil { - log.Fatalf("Failed to sign token: %v", err) - } - - fmt.Println("\n✅ Token Generated Successfully!") - fmt.Println("---------------------------------------------------") - fmt.Printf("User ID: %s\n", userID) - fmt.Printf("Email: %s\n", *email) - fmt.Printf("Role: %s\n", *role) - fmt.Println("---------------------------------------------------") - fmt.Println("\nUsage with curl:") - fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/v1/auth/me\n", tokenString) - fmt.Println("\nToken:") - fmt.Println(tokenString) -} diff --git a/tests/benchmarks/suite_test.go b/tests/benchmarks/suite_test.go index 6d950d3..66f574b 100644 --- a/tests/benchmarks/suite_test.go +++ b/tests/benchmarks/suite_test.go @@ -156,7 +156,6 @@ func setupTestData(ctx context.Context) { claims := middleware.UserClaims{ UserID: benchUserID, Email: "bench@example.com", - Role: "student", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/tests/integration/api_test.go b/tests/integration/api_test.go index b826787..850ae27 100644 --- a/tests/integration/api_test.go +++ b/tests/integration/api_test.go @@ -711,10 +711,10 @@ func TestBotRoutes(t *testing.T) { client := server.Client() ctx := context.Background() - faculty, err := q.CreateUser(ctx, database.CreateUserParams{ + devUser, err := q.CreateUser(ctx, database.CreateUserParams{ FirstName: "Bot", LastName: "Admin", - Role: database.NullUserRole{UserRole: database.UserRoleFaculty, Valid: true}, + Role: database.NullUserRole{UserRole: database.UserRoleDev, Valid: true}, }) require.NoError(t, err) @@ -725,7 +725,7 @@ func TestBotRoutes(t *testing.T) { }) require.NoError(t, err) - botToken := createIntegrationBotToken(t, client, server.URL, cfg.JWT.Secret, faculty.Uid) + botToken := createIntegrationBotToken(t, client, server.URL, cfg.JWT.Secret, devUser.Uid) meReq, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/bot/me", nil) meReq.Header.Set("X-Bot-Token", botToken) @@ -947,12 +947,11 @@ func TestBotRoutes(t *testing.T) { require.Equal(t, http.StatusNoContent, deleteOrgResp.StatusCode) } -func createIntegrationBotToken(t *testing.T, client *http.Client, serverURL, jwtSecret string, facultyID uuid.UUID) string { +func createIntegrationBotToken(t *testing.T, client *http.Client, serverURL, jwtSecret string, devUserID uuid.UUID) string { t.Helper() claims := middleware.UserClaims{ - UserID: facultyID.String(), - Role: string(database.UserRoleFaculty), + UserID: devUserID.String(), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), }, From f33780c828cffb9c86cc549b455448cdc0be12af Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 23 Mar 2026 18:26:35 -0400 Subject: [PATCH 5/6] feat(bot-token): added script to create bot token --- README.md | 17 +++++ internal/handler/auth.go | 2 +- scripts/create_bot_token/main.go | 115 +++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 scripts/create_bot_token/main.go diff --git a/README.md b/README.md index a01a645..4ba4678 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,12 @@ Seeds or updates a user in the database and prints a JWT for that user. `--email go run scripts/create_user/main.go --email dev@example.com --role dev ``` +### Create Bot Token +Creates a bot token in the database and prints the full `token_id.secret` value once for use with the `X-Bot-Token` header. `--name` and `--created-by` are required; `--hours 0` means the token does not expire. +```bash +go run scripts/create_bot_token/main.go --name my-bot --created-by 00000000-0000-0000-0000-000000000001 --hours 24 +``` + ### Run DB-Connected Scripts Without Go in the API Image If you are running the API and Postgres with Docker Compose, the API container does not include the Go toolchain. To run local Go scripts that need database access, start a one-off Go container on the same Compose network and mount the repository into it. @@ -149,6 +155,17 @@ docker run --rm \ go run scripts/create_user/main.go --email dev@example.com --role dev ``` +Bot token example: +```bash +docker run --rm \ + --network api_default \ + -v "$PWD":/app \ + -w /app \ + --env-file .env \ + golang:1.25 \ + go run scripts/create_bot_token/main.go --name my-bot --created-by 00000000-0000-0000-0000-000000000001 --hours 24 +``` + If your Compose project name is different, the network name will usually be `_default`. You can check it with: ```bash docker network ls diff --git a/internal/handler/auth.go b/internal/handler/auth.go index f44a48f..9c2cd5b 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -403,7 +403,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { h.respondJSON(w, http.StatusCreated, BotTokenResponse{ TokenID: token.TokenID, Name: token.Name, - Token: rawToken, // Only returned on creation! + Token: formatBotToken(token.TokenID, rawToken), // Only returned on creation CreatedAt: token.CreatedAt.Time, ExpiresAt: fromPgTimestamp(token.ExpiresAt), IsActive: token.IsActive.Bool, diff --git a/scripts/create_bot_token/main.go b/scripts/create_bot_token/main.go new file mode 100644 index 0000000..6f7b63c --- /dev/null +++ b/scripts/create_bot_token/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "flag" + "fmt" + "log" + "time" + + "github.com/capyrpi/api/internal/config" + "github.com/capyrpi/api/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" +) + +func main() { + envFile := flag.String("env", ".env", "Path to .env file") + name := flag.String("name", "", "Bot token name (required)") + createdBy := flag.String("created-by", "", "Creator user ID (UUID, required)") + hours := flag.Int("hours", 0, "Token validity in hours (0 means no expiry)") + flag.Parse() + + if *name == "" { + log.Fatal("name is required") + } + + creatorID, err := uuid.Parse(*createdBy) + if err != nil { + log.Fatalf("invalid created-by UUID: %v", err) + } + + if *hours < 0 { + log.Fatal("hours must be >= 0") + } + + if err := godotenv.Load(*envFile); err != nil { + log.Printf("Warning: Error loading .env file: %v", err) + } + + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + ctx := context.Background() + pool, err := database.NewPool(ctx, cfg.Database.URL) + if err != nil { + log.Fatalf("Unable to connect to database: %v", err) + } + defer pool.Close() + + rawToken, err := generateSecureToken(32) + if err != nil { + log.Fatalf("Failed to generate token: %v", err) + } + + hashedToken, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("Failed to hash token: %v", err) + } + + token, err := database.New(pool).CreateBotToken(ctx, database.CreateBotTokenParams{ + TokenHash: string(hashedToken), + Name: *name, + CreatedBy: creatorID, + ExpiresAt: expiryTimestamp(*hours), + }) + if err != nil { + log.Fatalf("Failed to create bot token: %v", err) + } + + fmt.Println("\nBot token created successfully.") + fmt.Println("---------------------------------------------------") + fmt.Printf("Token ID: %s\n", token.TokenID) + fmt.Printf("Name: %s\n", token.Name) + fmt.Printf("Created By: %s\n", token.CreatedBy) + if token.ExpiresAt.Valid { + fmt.Printf("Expires At: %s\n", token.ExpiresAt.Time.Format(time.RFC3339)) + } else { + fmt.Println("Expires At: never") + } + fmt.Println("---------------------------------------------------") + fmt.Println("\nUsage with curl:") + fmt.Printf("curl -H \"X-Bot-Token: %s\" http://localhost:8080/api/v1/bot/me\n", formatBotToken(token.TokenID, rawToken)) + fmt.Println("\nToken:") + fmt.Println(formatBotToken(token.TokenID, rawToken)) +} + +func generateSecureToken(byteLen int) (string, error) { + buf := make([]byte, byteLen) + if _, err := rand.Read(buf); err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func expiryTimestamp(hours int) pgtype.Timestamp { + if hours == 0 { + return pgtype.Timestamp{Valid: false} + } + + return pgtype.Timestamp{ + Time: time.Now().Add(time.Duration(hours) * time.Hour), + Valid: true, + } +} + +func formatBotToken(tokenID uuid.UUID, secret string) string { + return tokenID.String() + "." + secret +} From 601886cfb34f5d2adcf9c5fee5f53902f100d44e Mon Sep 17 00:00:00 2001 From: Shamik Karkhanis Date: Mon, 23 Mar 2026 18:52:13 -0400 Subject: [PATCH 6/6] fix: failing handler test due to bot token identifier mismatch --- internal/handler/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 9c2cd5b..11c92f9 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -403,7 +403,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) { h.respondJSON(w, http.StatusCreated, BotTokenResponse{ TokenID: token.TokenID, Name: token.Name, - Token: formatBotToken(token.TokenID, rawToken), // Only returned on creation + Token: rawToken, // Only returned on creation CreatedAt: token.CreatedAt.Time, ExpiresAt: fromPgTimestamp(token.ExpiresAt), IsActive: token.IsActive.Bool,