Skip to content

Commit e429a51

Browse files
feat(roles): db is now role source of truth
1 parent 3fe11dc commit e429a51

25 files changed

Lines changed: 584 additions & 263 deletions

README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,34 @@ make test-all
124124
## Development Scripts
125125
Helper scripts are located in the `scripts/` directory.
126126

127-
### Create Development User
128-
Seeds a user into the DB and generates a valid JWT for testing.
127+
### Create User
128+
Seeds or updates a user in the database and prints a JWT for that user. `--email` is required; the other fields have defaults.
129129
```bash
130-
go run scripts/create_dev_user/main.go
130+
go run scripts/create_user/main.go --email dev@example.com --role dev
131131
```
132132

133-
### Generate Token
134-
Manually generates a JWT for an existing user (by email).
133+
### Run DB-Connected Scripts Without Go in the API Image
134+
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.
135+
136+
Current local network:
137+
```bash
138+
api_default
139+
```
140+
141+
Example:
142+
```bash
143+
docker run --rm \
144+
--network api_default \
145+
-v "$PWD":/app \
146+
-w /app \
147+
--env-file .env \
148+
golang:1.25 \
149+
go run scripts/create_user/main.go --email dev@example.com --role dev
150+
```
151+
152+
If your Compose project name is different, the network name will usually be `<project>_default`. You can check it with:
135153
```bash
136-
go run scripts/generate_token/main.go
154+
docker network ls
137155
```
138156

139157
## Project Structure

docker-compose.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ services:
2525
db:
2626
condition: service_healthy
2727

28-
# tunnel:
29-
# image: cloudflare/cloudflared:latest
30-
# restart: unless-stopped
31-
# command: tunnel run
32-
# env_file:
33-
# - .env
34-
# depends_on:
35-
# - api
28+
tunnel:
29+
image: cloudflare/cloudflared:latest
30+
restart: unless-stopped
31+
command: tunnel run
32+
env_file:
33+
- .env
34+
depends_on:
35+
- api
3636

3737
volumes:
3838
pgdata:

internal/database/models.go

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/database/queries.sql

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,21 @@ ON CONFLICT (uid, eid) DO UPDATE SET is_attending = $3;
123123
DELETE FROM event_registrations WHERE uid = $1 AND eid = $2;
124124

125125
-- name: IsEventAdmin :one
126-
SELECT is_admin FROM event_registrations WHERE uid = $1 AND eid = $2;
126+
SELECT EXISTS (
127+
SELECT 1
128+
FROM event_registrations er
129+
WHERE er.uid = $1
130+
AND er.eid = $2
131+
AND er.is_admin = TRUE
132+
)
133+
OR EXISTS (
134+
SELECT 1
135+
FROM event_hosting eh
136+
JOIN org_members om ON om.oid = eh.oid
137+
WHERE eh.eid = $2
138+
AND om.uid = $1
139+
AND om.is_admin = TRUE
140+
);
127141

128142
-- name: GetUserEvents :many
129143
SELECT e.*, er.is_attending, er.is_admin, er.date_registered

internal/database/queries.sql.go

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/dto/dto.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type CreateUserRequest struct {
1717
SchoolEmail string `json:"school_email,omitempty" validate:"omitempty,email"`
1818
Phone string `json:"phone,omitempty"`
1919
GradYear int `json:"grad_year,omitempty" validate:"omitempty,gte=2000,lte=2100"`
20-
Role string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external"`
20+
Role string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external dev"`
2121
}
2222

2323
type UpdateUserRequest struct {
@@ -27,7 +27,7 @@ type UpdateUserRequest struct {
2727
SchoolEmail *string `json:"school_email,omitempty" validate:"omitempty,email"`
2828
Phone *string `json:"phone,omitempty"`
2929
GradYear *int `json:"grad_year,omitempty" validate:"omitempty,gte=2000,lte=2100"`
30-
Role *string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external"`
30+
Role *string `json:"role,omitempty" validate:"omitempty,oneof=student alumni faculty external dev"`
3131
}
3232

3333
type UserResponse struct {

internal/handler/auth.go

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) {
311311

312312
// ListBotTokens lists all bot tokens
313313
// @Summary List bot tokens
314-
// @Description Returns all bot tokens (requires faculty role)
314+
// @Description Returns all bot tokens (requires dev role)
315315
// @Tags bot
316316
// @Accept json
317317
// @Produce json
@@ -320,7 +320,7 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) {
320320
// @Security CookieAuth
321321
// @Router /bot/tokens [get]
322322
func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) {
323-
if !h.requireFaculty(w, r) {
323+
if !h.requireDev(w, r) {
324324
return
325325
}
326326

@@ -346,7 +346,7 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) {
346346

347347
// CreateBotToken creates a new bot token
348348
// @Summary Create bot token
349-
// @Description Creates a new bot token (requires faculty role). The raw token is returned only once and must be stored by the caller.
349+
// @Description Creates a new bot token (requires dev role). The raw token is returned only once and must be stored by the caller.
350350
// @Tags bot
351351
// @Accept json
352352
// @Produce json
@@ -357,7 +357,7 @@ func (h *Handler) ListBotTokens(w http.ResponseWriter, r *http.Request) {
357357
// @Security CookieAuth
358358
// @Router /bot/tokens [post]
359359
func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) {
360-
claims, ok := h.requireFacultyClaims(w, r)
360+
claims, ok := h.requireDevClaims(w, r)
361361
if !ok {
362362
return
363363
}
@@ -412,7 +412,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) {
412412

413413
// RevokeBotToken revokes a bot token
414414
// @Summary Revoke bot token
415-
// @Description Revokes a bot token (requires faculty role)
415+
// @Description Revokes a bot token (requires dev role)
416416
// @Tags bot
417417
// @Accept json
418418
// @Produce json
@@ -423,7 +423,7 @@ func (h *Handler) CreateBotToken(w http.ResponseWriter, r *http.Request) {
423423
// @Security CookieAuth
424424
// @Router /bot/tokens/{token_id} [delete]
425425
func (h *Handler) RevokeBotToken(w http.ResponseWriter, r *http.Request) {
426-
if !h.requireFaculty(w, r) {
426+
if !h.requireDev(w, r) {
427427
return
428428
}
429429

@@ -475,7 +475,6 @@ func (h *Handler) generateJWT(user database.User) (string, error) {
475475
claims := &middleware.UserClaims{
476476
UserID: user.Uid.String(),
477477
Email: getEmail(user),
478-
Role: string(user.Role.UserRole),
479478
RegisteredClaims: jwt.RegisteredClaims{
480479
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(h.Config.JWT.ExpiryHours) * time.Hour)),
481480
IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -585,7 +584,8 @@ func (h *Handler) verifyStateCookie(w http.ResponseWriter, r *http.Request, stat
585584
}
586585

587586
func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName string) (database.User, error) {
588-
pgEmail := toPgTextFromString(email)
587+
normalizedEmail := normalizeEmail(email)
588+
pgEmail := toPgTextFromString(normalizedEmail)
589589

590590
// Check if user exists
591591
user, err := h.queries.GetUserByEmail(ctx, pgEmail)
@@ -603,7 +603,7 @@ func (h *Handler) upsertUser(ctx context.Context, email, firstName, lastName str
603603
LastName: lastName,
604604
PersonalEmail: pgEmail, // Default to personal email for oauth
605605
SchoolEmail: pgtype.Text{Valid: false},
606-
Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true}, // Default role
606+
Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true},
607607
})
608608
}
609609

@@ -628,35 +628,3 @@ func generateSecureToken(length int) (string, error) {
628628
func formatBotToken(tokenID uuid.UUID, secret string) string {
629629
return tokenID.String() + "." + secret
630630
}
631-
632-
func (h *Handler) requireFaculty(w http.ResponseWriter, r *http.Request) bool {
633-
_, ok := h.requireFacultyClaims(w, r)
634-
return ok
635-
}
636-
637-
func (h *Handler) requireFacultyClaims(w http.ResponseWriter, r *http.Request) (*middleware.UserClaims, bool) {
638-
claims, ok := middleware.GetUserClaims(r.Context())
639-
if !ok {
640-
h.respondError(w, http.StatusUnauthorized, "Not authenticated")
641-
return nil, false
642-
}
643-
644-
uid, err := uuid.Parse(claims.UserID)
645-
if err != nil {
646-
h.respondError(w, http.StatusUnauthorized, "Invalid user ID in token")
647-
return nil, false
648-
}
649-
650-
user, err := h.queries.GetUserByID(r.Context(), uid)
651-
if err != nil {
652-
h.handleDBError(w, err)
653-
return nil, false
654-
}
655-
656-
if !user.Role.Valid || user.Role.UserRole != database.UserRoleFaculty {
657-
h.respondError(w, http.StatusForbidden, "Faculty role required")
658-
return nil, false
659-
}
660-
661-
return claims, true
662-
}

internal/handler/auth_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package handler
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/capyrpi/api/internal/config"
8+
"github.com/capyrpi/api/internal/database"
9+
"github.com/capyrpi/api/internal/database/mocks"
10+
"github.com/google/uuid"
11+
"github.com/jackc/pgx/v5"
12+
"github.com/jackc/pgx/v5/pgtype"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/mock"
15+
)
16+
17+
func TestUpsertUserCreatesStudentByDefault(t *testing.T) {
18+
mockQueries := mocks.NewQuerier(t)
19+
h := New(mockQueries, &config.Config{Env: "development"})
20+
21+
mockQueries.On("GetUserByEmail", mock.Anything, pgtype.Text{
22+
String: "student@example.com",
23+
Valid: true,
24+
}).Return(database.User{}, pgx.ErrNoRows).Once()
25+
mockQueries.On("CreateUser", mock.Anything, mock.MatchedBy(func(arg database.CreateUserParams) bool {
26+
return arg.FirstName == "Grace" &&
27+
arg.LastName == "Hopper" &&
28+
arg.PersonalEmail.Valid &&
29+
arg.PersonalEmail.String == "student@example.com" &&
30+
arg.Role.Valid &&
31+
arg.Role.UserRole == database.UserRoleStudent
32+
})).Return(database.User{
33+
Uid: uuid.New(),
34+
PersonalEmail: pgtype.Text{String: "student@example.com", Valid: true},
35+
Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true},
36+
}, nil).Once()
37+
38+
user, err := h.upsertUser(context.Background(), " Student@Example.com ", "Grace", "Hopper")
39+
40+
assert.NoError(t, err)
41+
assert.Equal(t, database.UserRoleStudent, user.Role.UserRole)
42+
}
43+
44+
func TestUpsertUserReturnsExistingUserWithoutRolePromotion(t *testing.T) {
45+
mockQueries := mocks.NewQuerier(t)
46+
h := New(mockQueries, &config.Config{Env: "development"})
47+
userID := uuid.New()
48+
49+
mockQueries.On("GetUserByEmail", mock.Anything, pgtype.Text{
50+
String: "dev@example.com",
51+
Valid: true,
52+
}).Return(database.User{
53+
Uid: userID,
54+
PersonalEmail: pgtype.Text{String: "dev@example.com", Valid: true},
55+
Role: database.NullUserRole{UserRole: database.UserRoleStudent, Valid: true},
56+
}, nil).Once()
57+
58+
user, err := h.upsertUser(context.Background(), " dev@example.com ", "Katherine", "Johnson")
59+
60+
assert.NoError(t, err)
61+
assert.Equal(t, userID, user.Uid)
62+
assert.Equal(t, database.UserRoleStudent, user.Role.UserRole)
63+
}

0 commit comments

Comments
 (0)