Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:

- name: "🌐 Preserve CNAME"
working-directory: ./front
run: echo "operafix.harryvasanth.com" > dist/CNAME
run: echo "dev.lusopoint.com" > dist/CNAME

- name: "⚙️ Setup GitHub Pages"
uses: actions/configure-pages@v6
Expand Down
15 changes: 7 additions & 8 deletions SYSTEM_DESIGN_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,13 @@ The Go auth service issues JWTs with a `https://hasura.io/jwt/claims` namespace.

`company_id` is the outermost filter on every single table. A tenant can never see another tenant's data regardless of role.

| Role | issues — SELECT | INSERT | UPDATE |
| ------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------ | ----------------------------------- |
| `employee` | `company_id = x-hasura-company-id` AND `reporter_id = x-hasura-user-id` | ✓ own reports only | Comments only |
| `technician` | `company_id = x-hasura-company-id` AND (`assigned_to = x-hasura-user-id` OR `location_id IN x-hasura-location-ids`) | ✗ | Status + notes on assigned issues |
| `location_manager` | `company_id = x-hasura-company-id` AND `location_id IN x-hasura-location-ids` | ✓ | All fields for their locations |
| `ops_manager` | `company_id = x-hasura-company-id` | ✓ | All fields across company |
| `admin` | `company_id = x-hasura-company-id` | ✓ | All fields |
| `super_admin` | No filter | ✓ | All fields — platform operator only |
| Role | issues — SELECT | INSERT | UPDATE |
| ------------------ | ------------------------------------------------------------------------------------------------------------------- | ------------------ | --------------------------------- |
| `employee` | `company_id = x-hasura-company-id` AND `reporter_id = x-hasura-user-id` | ✓ own reports only | Comments only |
| `technician` | `company_id = x-hasura-company-id` AND (`assigned_to = x-hasura-user-id` OR `location_id IN x-hasura-location-ids`) | ✗ | Status + notes on assigned issues |
| `location_manager` | `company_id = x-hasura-company-id` AND `location_id IN x-hasura-location-ids` | ✓ | All fields for their locations |
| `ops_manager` | `company_id = x-hasura-company-id` | ✓ | All fields across company |
| `admin` | no filter | ✓ | All fields only for us |

> `company_id` is present on every table and verified in Hasura middleware on every request. A misconfigured JWT cannot leak data between tenants.

Expand Down
8 changes: 0 additions & 8 deletions api/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
FROM golang:1.26-alpine AS base

# Install system dependencies:
# git — required by `go mod download` for private modules
# and for some go:generate tools
# curl — used in healthchecks and debugging inside the container
# ca-certificates — TLS root certificates so the binary can make HTTPS
# calls to Postmark, Twilio, R2, etc.
# tzdata — timezone data so time.LoadLocation("Europe/Lisbon")
# works inside the container (scratch has no timezone DB)
RUN apk add --no-cache git curl ca-certificates tzdata

WORKDIR /app
Expand Down
36 changes: 29 additions & 7 deletions api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"api/internal/auth"
"api/internal/docs"
"api/internal/user"
"api/pkg/config"
"api/pkg/database"
)
Expand All @@ -24,6 +25,24 @@ var (
BuildTime = "unknown"
)

func corsMiddleware(allowedOrigin string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")

if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}

next.ServeHTTP(w, r)
})
}
}

func main() {
// config setup
cfg := config.Load()
Expand Down Expand Up @@ -66,13 +85,17 @@ func main() {
slog.Info("database connected")

// services
// auth service
authService := auth.NewService(
db,
cfg.JWTSecret,
cfg.JWTAccessExpiry,
cfg.JWTRefreshExpiry,
)
// user service
usersService := user.NewService(db, authService)

// middleware, security layer for routes
authMiddleware := auth.NewMiddleware(
cfg.JWTSecret,
cfg.JWTAccessExpiry,
Expand All @@ -90,22 +113,21 @@ func main() {
})

// auth routes
authHandler := auth.NewHandler(authService, authMiddleware, !cfg.IsDev())
authHandler := auth.NewHandler(authService, authMiddleware, cfg, !cfg.IsDev())
authHandler.RegisterRoutes(mux)

// docs routes
docsHandler := docs.NewHandler(!cfg.IsDev())
docsHandler.RegisterRoutes(mux)

// TODO: register additional route groups here as they are built:
// equipmentHandler.RegisterRoutes(mux)
// issueHandler.RegisterRoutes(mux)
// ...
// user routes
usersHandler := user.NewHandler(usersService, authMiddleware)
usersHandler.RegisterRoutes(mux)

// http server
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: mux,
Handler: corsMiddleware(cfg.CORSAllowedOrigin)(mux),
// NOTE: some timeouts to prevent slow clients maybe not needed
// time to read the full request
ReadTimeout: 5 * time.Second,
Expand All @@ -118,7 +140,7 @@ func main() {
}

// shitdown gracefully
// Listen for SIGINT (Ctrl+C) and SIGTERM (docker compose stop / Railway deploy)
// Listen for SIGINT (Ctrl+C) and SIGTERM
// On signal: stop accepting new connections, wait up to 30s for in-flight
// requests to finish, then exit cleanly
quit := make(chan os.Signal, 1)
Expand Down
96 changes: 47 additions & 49 deletions api/internal/auth/handler.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
package auth

import (
"api/pkg/config"
"encoding/json"
"errors"
"github.com/google/uuid"
"fmt"
"net/http"
"strconv"
"time"
)

// handler holds the HTTP handlers for all auth endpoints
type Handler struct {
svc *Service
middleware *Middleware
cfg *config.Config
isProd bool
}

func NewHandler(svc *Service, middleware *Middleware, isProd bool) *Handler {
return &Handler{svc: svc, middleware: middleware, isProd: isProd}
func NewHandler(svc *Service, middleware *Middleware, cfg *config.Config, isProd bool) *Handler {
return &Handler{svc: svc, middleware: middleware, cfg: cfg, isProd: isProd}
}

// all auth endpoints
Expand All @@ -37,10 +40,10 @@ type loginRequest struct {
}

type loginResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User userDTO `json:"user"`
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User AuthResponse `json:"user"`
}

func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -75,7 +78,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
AccessToken: result.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(15 * time.Minute / time.Second),
User: toUserDTO(result.User),
User: toAuthResponse(result.User),
})
}

Expand Down Expand Up @@ -107,7 +110,7 @@ func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) {
AccessToken: result.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(15 * time.Minute / time.Second),
User: toUserDTO(result.User),
User: toAuthResponse(result.User),
})
}

Expand All @@ -118,20 +121,20 @@ func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
token, ok := extractBearerToken(r)
if ok {
if claims, err := h.svc.tokens.verify(token); err == nil {
userID, _ := parseUUID(claims.Subject)
_ = h.svc.Logout(r.Context(), userID)
if userID, err := strconv.ParseInt(claims.Subject, 10, 64); err == nil {
_ = h.svc.Logout(r.Context(), userID)
}
}
}

// Always clear the cookie, even if token parsing failed.
h.clearRefreshCookie(w)

writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}

// GET /auth/me
type meResponse struct {
User userDTO `json:"user"`
User AuthResponse `json:"user"`
}

func (h *Handler) me(w http.ResponseWriter, r *http.Request) {
Expand All @@ -147,7 +150,7 @@ func (h *Handler) me(w http.ResponseWriter, r *http.Request) {
return
}

writeJSON(w, http.StatusOK, meResponse{User: toUserDTO(user)})
writeJSON(w, http.StatusOK, meResponse{User: toAuthResponse(user)})
}

//POST /auth/magic-link
Expand All @@ -156,6 +159,11 @@ type magicLinkRequest struct {
Email string `json:"email"`
}

// TODO: send email with the magic link URL:
//
// https://DOMAIN/api/auth/magic-link?token=<rawToken>
//
// The email service will be wired in the notify package
func (h *Handler) sendMagicLink(w http.ResponseWriter, r *http.Request) {
var req magicLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Expand All @@ -167,53 +175,50 @@ func (h *Handler) sendMagicLink(w http.ResponseWriter, r *http.Request) {
return
}

// Service returns nil token when email doesn't exist — prevents enumeration.
// Service returns nil token when email doesn't exist — prevents enumeration
// We respond with 200 in both cases so attackers can't probe for emails.
_, err := h.svc.SendMagicLink(r.Context(), SendMagicLinkInput{Email: req.Email})
magicLinkToken, err := h.svc.SendMagicLink(r.Context(), SendMagicLinkInput{Email: req.Email})
if err != nil {
// Log internally but return 200 to the client.
// TODO: inject a logger and log err here
fmt.Println(err)

// log internally but return 200 to the client
writeJSON(w, http.StatusOK, map[string]string{
"message": "if that email exists, a login link has been sent",
})
return
}

// TODO: send email via Postmark with the magic link URL:
// https://app.operafix.com/auth/verify?token=<rawToken>
// The email service will be wired in the notify package.
fmt.Println("FOR TESTING - magic link token:", h.cfg.FrontendURL+"/api/auth/magic-link?token="+magicLinkToken)

writeJSON(w, http.StatusOK, map[string]string{
"message": "if that email exists, a login link has been sent",
})
}

// GET /auth/magic-link?token=<raw>
// validate the token and redirect the user to the front to be validated
func (h *Handler) verifyMagicLink(w http.ResponseWriter, r *http.Request) {
rawToken := r.URL.Query().Get("token")
if rawToken == "" {
writeError(w, http.StatusBadRequest, "missing token")
http.Redirect(w, r, h.cfg.FrontendURL+"/auth/error?reason=missing_token", http.StatusSeeOther)
return
}

result, err := h.svc.VerifyMagicLink(r.Context(), rawToken)
if err != nil {
if errors.Is(err, ErrUnauthorized) {
writeError(w, http.StatusUnauthorized, "invalid or expired link")
http.Redirect(w, r, h.cfg.FrontendURL+"/auth/error?reason=invalid_token", http.StatusSeeOther)
return
}
writeError(w, http.StatusInternalServerError, "verification failed")
http.Redirect(w, r, h.cfg.FrontendURL+"/auth/error?reason=server_error", http.StatusSeeOther)
return
}

h.setRefreshCookie(w, result.RefreshToken, result.RefreshExpiresAt)

writeJSON(w, http.StatusOK, loginResponse{
AccessToken: result.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(15 * time.Minute / time.Second),
User: toUserDTO(result.User),
})
// the token is already set on the cookie
http.Redirect(w, r, h.cfg.FrontendURL+"/auth/verify", http.StatusSeeOther)
}

// Cookie helpers
Expand Down Expand Up @@ -250,26 +255,23 @@ func (h *Handler) clearRefreshCookie(w http.ResponseWriter) {
})
}

// DTOs

// userDTO is the public-facing user representation.
// Never expose password_hash — not even its existence.
type userDTO struct {
ID string `json:"id"`
CompanyID string `json:"company_id"`
Email string `json:"email"`
Role string `json:"role"`
type AuthResponse struct {
ID int64 `json:"id"`
CompanyID int64 `json:"companyId"`
Email string `json:"email"`
Role Role `json:"role"`
DefaultLocationID *int64 `json:"defaultLocationId,omitempty"`
}

func toUserDTO(u *User) userDTO {
return userDTO{
ID: u.ID.String(),
CompanyID: u.CompanyID.String(),
Email: u.Email,
Role: string(u.Role),
func toAuthResponse(u *User) AuthResponse {
return AuthResponse{
ID: u.ID,
CompanyID: u.CompanyId,
Email: u.Email,
Role: u.Role,
DefaultLocationID: u.DefaultLocationID,
}
}

func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
Expand All @@ -279,7 +281,3 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}
Loading