From 2fd3a1d57b85ecbd34ccfa49ef543603786fb5e6 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 24 Apr 2026 10:55:40 +0200 Subject: [PATCH] feat: add permissions on user create, CORS middleware, cors server playground. --- projects/greenlight/cmd/api/main.go | 16 +++++ projects/greenlight/cmd/api/middleware.go | 27 +++++++++ projects/greenlight/cmd/api/routes.go | 2 +- projects/greenlight/cmd/api/users.go | 7 +++ .../cmd/examples/cors/preflight/main.go | 58 +++++++++++++++++++ .../cmd/examples/cors/simple/main.go | 51 ++++++++++++++++ .../greenlight-bruno/Users/Create User.yml | 4 +- .../greenlight/internal/data/permissions.go | 17 ++++++ 8 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 projects/greenlight/cmd/examples/cors/preflight/main.go create mode 100644 projects/greenlight/cmd/examples/cors/simple/main.go diff --git a/projects/greenlight/cmd/api/main.go b/projects/greenlight/cmd/api/main.go index f8a3d21..94cf216 100644 --- a/projects/greenlight/cmd/api/main.go +++ b/projects/greenlight/cmd/api/main.go @@ -5,6 +5,7 @@ import ( "database/sql" "flag" "os" + "strings" "sync" "time" @@ -48,6 +49,10 @@ type config struct { password string sender string } + // Add a cors struct and trustedOrigins field with the type []string. + cors struct { + trustedOrigins []string + } } // Define an application struct to hold the dependencies for our HTTP handlers, helpers, @@ -101,6 +106,17 @@ func main() { flag.StringVar(&cfg.smtp.password, "smtp-password", "d06a774b484ca3", "SMTP password") flag.StringVar(&cfg.smtp.sender, "smtp-sender", "Greenlight ", "SMTP sender") + // Use the flag.Func() function to process the -cors-trusted-origins command line + // flag. In this we use the strings.Fields() function to split the flag value into a + // slice based on whitespace characters and assign it to our config struct. + // Importantly, if the -cors-trusted-origins flag is not present, contains the empty + // string, or contains only whitespace, then strings.Fields() will return an empty + // []string slice. + flag.Func("cors-trusted-origins", "Trusted CORS origins (space separated)", func(val string) error { + cfg.cors.trustedOrigins = strings.Fields(val) + return nil + }) + flag.Parse() // Call the openDB() helper function (see below) to create the connection pool, diff --git a/projects/greenlight/cmd/api/middleware.go b/projects/greenlight/cmd/api/middleware.go index a7cf295..de36a43 100644 --- a/projects/greenlight/cmd/api/middleware.go +++ b/projects/greenlight/cmd/api/middleware.go @@ -244,3 +244,30 @@ func (app *application) requirePermission(code string, next http.HandlerFunc) ht // Wrap this with the requireActivatedUser() middleware before returning it. return app.requireActivatedUser(fn) } + +func (app *application) enableCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Add the "Vary: Origin" header. + w.Header().Add("Vary", "Origin") + + // Get the value of the request's Origin header. + origin := r.Header.Get("Origin") + + // Only run this if there's an Origin request header present AND at least one + // trusted origin is configured. + if origin != "" && len(app.config.cors.trustedOrigins) != 0 { + // Loop through the list of trusted origins, checking to see if the request + // origin exactly matches one of them. + for i := range app.config.cors.trustedOrigins { + if origin == app.config.cors.trustedOrigins[i] { + // If there is a match, then set a "Access-Control-Allow-Origin + // response header with the request origin as the value. + w.Header().Set("Access-Control-Allow-Origin", origin) + } + } + } + + // Call the next handler in the chain. + next.ServeHTTP(w, r) + }) +} diff --git a/projects/greenlight/cmd/api/routes.go b/projects/greenlight/cmd/api/routes.go index 2959028..c259c05 100644 --- a/projects/greenlight/cmd/api/routes.go +++ b/projects/greenlight/cmd/api/routes.go @@ -28,5 +28,5 @@ func (app *application) routes() http.Handler { router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler) - return app.recoverPanic(app.rateLimit(app.authenticate(router))) + return app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))) } diff --git a/projects/greenlight/cmd/api/users.go b/projects/greenlight/cmd/api/users.go index b6fa920..a81c403 100644 --- a/projects/greenlight/cmd/api/users.go +++ b/projects/greenlight/cmd/api/users.go @@ -63,6 +63,13 @@ func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Reque return } + // Add the "movies:read" permission for the new user. + err = app.models.Permissions.AddForUser(user.ID, "movies:read") + if err != nil { + app.serverErrorResponse(w, r, err) + return + } + // After the user record has been created in the database, generate a new activation // token for the user. token, err := app.models.Tokens.New(user.ID, 3*24*time.Hour, data.ScopeActivation) diff --git a/projects/greenlight/cmd/examples/cors/preflight/main.go b/projects/greenlight/cmd/examples/cors/preflight/main.go new file mode 100644 index 0000000..f934ea7 --- /dev/null +++ b/projects/greenlight/cmd/examples/cors/preflight/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "flag" + "log" + "net/http" +) + +// Define a string constant containing the HTML for the webpage. This consists of a

+// header tag, and some JavaScript which calls our POST /v1/tokens/authentication +// endpoint and writes the response body to inside the
tag. +const html = ` + + + + + + +

Preflight CORS

+
+ + +` + +func main() { + addr := flag.String("addr", ":9000", "Server address") + flag.Parse() + + log.Printf("starting server on %s", *addr) + + err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(html)) + })) + log.Fatal(err) +} diff --git a/projects/greenlight/cmd/examples/cors/simple/main.go b/projects/greenlight/cmd/examples/cors/simple/main.go new file mode 100644 index 0000000..e3b8e00 --- /dev/null +++ b/projects/greenlight/cmd/examples/cors/simple/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "log" + "net/http" +) + +// Define a string constant containing the HTML for the webpage. This consists of a

+// header tag, and some JavaScript which fetches the JSON from our GET /v1/healthcheck +// endpoint and writes it to inside the
element. +const html = ` + + + + + + +

Simple CORS

+
+ + +` + +func main() { + // Make the server address configurable at runtime via a command-line flag. + addr := flag.String("addr", ":9000", "Server address") + flag.Parse() + + log.Printf("starting server on %s", *addr) + + // Start a HTTP server listening on the given address, which responds to all + // requests with the webpage HTML above. + err := http.ListenAndServe(*addr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(html)) + })) + log.Fatal(err) +} diff --git a/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml b/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml index ec6f1c9..e5bdf84 100644 --- a/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml +++ b/projects/greenlight/docs/api/greenlight-bruno/Users/Create User.yml @@ -10,8 +10,8 @@ http: type: json data: |- { - "name": "Bob", - "email": "bob@example.com", + "name": "Grace", + "email": "grace@example.com", "password": "pa55word" } auth: inherit diff --git a/projects/greenlight/internal/data/permissions.go b/projects/greenlight/internal/data/permissions.go index 29df375..c54b132 100644 --- a/projects/greenlight/internal/data/permissions.go +++ b/projects/greenlight/internal/data/permissions.go @@ -4,6 +4,8 @@ import ( "context" "database/sql" "time" + + "github.com/lib/pq" ) // Define a Permissions slice, which we will use to will hold the permission codes (like @@ -64,3 +66,18 @@ func (m PermissionModel) GetAllForUser(userID int64) (Permissions, error) { return permissions, nil } + +// AddForUser - Add the provided permission codes for a specific user. Notice that we're using a +// variadic parameter for the codes so that we can assign multiple permissions in a +// single call. +func (m PermissionModel) AddForUser(userID int64, codes ...string) error { + query := ` + INSERT INTO users_permissions + SELECT $1, permissions.id FROM permissions WHERE permissions.code = ANY($2)` + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := m.DB.ExecContext(ctx, query, userID, pq.Array(codes)) + return err +}