diff --git a/Makefile b/Makefile index ceaef7c0..0340b22b 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,8 @@ PLUGINS=\ types \ zaplogger \ zerologger \ - arnz + arnz \ + tscap PROTOC_VERSION=27.1 ifeq ($(GOOS),linux) diff --git a/tscap/Makefile b/tscap/Makefile new file mode 100644 index 00000000..6ff0805e --- /dev/null +++ b/tscap/Makefile @@ -0,0 +1,27 @@ +#! /usr/bin/make +# +# Makefile for goa v3 tscap plugin +# +# Targets: +# - "gen" generates the goa files for the example services +# - "example" generates the example files for the example services + +# include common Makefile content for plugins +PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +include ../plugins.mk + +gen: + @goa gen goa.design/plugins/v3/tscap/example/design -o "$(PLUGIN_DIR)/example" && \ + make example + +example: + @rm -rf "$(PLUGIN_DIR)/example/cmd" + goa example goa.design/plugins/v3/tscap/example/design -o "$(PLUGIN_DIR)/example" + +build-examples: + @cd "$(PLUGIN_DIR)/example" && \ + go build ./cmd/example + +clean: + @cd "$(PLUGIN_DIR)/example" && \ + rm -f example diff --git a/tscap/README.md b/tscap/README.md new file mode 100644 index 00000000..a4bd3882 --- /dev/null +++ b/tscap/README.md @@ -0,0 +1,120 @@ +# Tailscale App Capabilities Plugin + +The `tscap` plugin is a [Goa](https://github.com/goadesign/goa/tree/v3) plugin +that provides declarative authorization using Tailscale app capabilities. + +Inspired by the implementation of [arnz](../arnz/README.md). + +## Requirements + +- Tailscale 1.92+ (app capabilities feature) +- Service must be served via `tailscale serve --accept-app-caps` + +## Enabling the Plugin + +To enable the plugin and make use of the tscap DSL simply import both the `tscap` +and the `dsl` packages as follows: + +```go +import ( + . "goa.design/goa/v3/dsl" + tscap "goa.design/plugins/v3/tscap/dsl" +) +``` + +### Tailscale Setup + +```bash +tailscale serve --accept-app-caps example.com/cap/myapp https+insecure://localhost:8080 +``` + +### ACL Grants + +Configure grants in your tailnet policy: + +```json +{ + "grants": [ + { + "src": ["group:developers"], + "dst": ["tag:myapp"], + "app": { + "example.com/cap/myapp": [{"action": ["*"], "resources": ["*"]}] + } + }, + { + "src": ["group:finance"], + "dst": ["tag:myapp"], + "app": { + "example.com/cap/myapp": [{"action": ["read"], "resources": ["items/*"]}] + } + } + ] +} +``` + +## Effects on Code Generation + +Enabling the plugin changes the behavior of the `gen` command of the `goa` tool. + +The `gen` command output is modified as follows: + +1. Generates middleware that extracts the `Tailscale-App-Capabilities` header +2. Parses the JSON capabilities from the header +3. Checks if the caller's grants satisfy the method's requirements +4. Returns 401 if header is missing, 403 if permissions are insufficient + +## Design + +This plugin adds the following functions to the Goa DSL: + +* `Require` declares that the method requires a Tailscale app capability with the + specified action and resource. +* `AllowAnonymous` marks the method as not requiring any capability check. Requests + without the capabilities header will be allowed through. + +The usage and effect of the DSL functions are described in the [Godocs](https://godoc.org/goa.design/plugins/v3/tscap/dsl) + +Here is an example defining capability requirements at a method level. + +```go +var _ = Service("myservice", func() { + Method("list", func() { + // Requires the caller to have "read" action on "*" resource + tscap.Require("example.com/cap/myapp", "read", "*") + HTTP(func() { GET("/items") }) + }) + + Method("create", func() { + // Requires the caller to have "write" action on "items/*" resource + tscap.Require("example.com/cap/myapp", "write", "items/*") + HTTP(func() { POST("/items") }) + }) + + Method("health", func() { + // No capability check required + tscap.AllowAnonymous() + HTTP(func() { GET("/health") }) + }) +}) +``` + +## Matching Semantics + +Grants in Tailscale ACLs can use wildcards (`*`). The DSL specifies exact requirements: + +| Grant Action | Required Action | Match? | +|--------------|-----------------|--------| +| `["*"]` | `"read"` | Yes | +| `["read"]` | `"read"` | Yes | +| `["write"]` | `"read"` | No | + +| Grant Resource | Required Resource | Match? | +|----------------|-------------------|--------| +| `["*"]` | `"items/123"` | Yes | +| `["items/*"]` | `"items/*"` | Yes (exact) | +| `["items/123"]` | `"items/456"` | No | + +## References + +- [Application capabilities](https://tailscale.com/docs/features/access-control/grants/grants-app-capabilities) diff --git a/tscap/auth/auth.go b/tscap/auth/auth.go new file mode 100644 index 00000000..f1f19b06 --- /dev/null +++ b/tscap/auth/auth.go @@ -0,0 +1,107 @@ +package auth + +import ( + "encoding/json" + "net/http" +) + +const Header = "Tailscale-App-Capabilities" + +// Capabilities represents parsed app capabilities from the Tailscale header. +// The map key is the capability name (e.g., "example.com/cap/myapp"). +type Capabilities map[string][]Grant + +// Grant represents a single permission grant from a Tailscale ACL. +type Grant struct { + Action []string `json:"action"` + Resources []string `json:"resources"` +} + +// Requirement specifies what capability is needed for a method. +type Requirement struct { + Capability string + Action string + Resource string +} + +// Gate stores the authorization requirements for a method. +type Gate struct { + MethodName string + AllowAnonymous bool + Requirement *Requirement +} + +// ParseCapabilities extracts capabilities from the Tailscale header. +func ParseCapabilities(w http.ResponseWriter, r *http.Request) (Capabilities, bool) { + header := r.Header.Get(Header) + if header == "" { + WriteUnauthenticated(w, "missing capabilities header") + return nil, false + } + + var caps Capabilities + if err := json.Unmarshal([]byte(header), &caps); err != nil { + WriteUnauthenticated(w, "invalid capabilities header") + return nil, false + } + + return caps, true +} + +// Check verifies the caller has the required capability. +func Check(w http.ResponseWriter, caps Capabilities, req Requirement) bool { + grants, ok := caps[req.Capability] + if !ok { + WriteUnauthorized(w, "missing required capability") + return false + } + + for _, g := range grants { + if matchesAction(g.Action, req.Action) && matchesResource(g.Resources, req.Resource) { + return true + } + } + + WriteUnauthorized(w, "insufficient permissions") + return false +} + +// matchesAction checks if the granted actions satisfy the required action. +func matchesAction(granted []string, required string) bool { + for _, a := range granted { + if a == "*" || a == required { + return true + } + } + return false +} + +// matchesResource checks if the granted resources satisfy the required resource. +func matchesResource(granted []string, required string) bool { + for _, r := range granted { + if r == "*" || r == required { + return true + } + } + return false +} + +// WriteUnauthenticated writes a 401 response. +func WriteUnauthenticated(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthenticated", + "message": message, + }) +} + +// WriteUnauthorized writes a 403 response. +func WriteUnauthorized(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "unauthorized", + "message": message, + }) +} diff --git a/tscap/auth/auth_test.go b/tscap/auth/auth_test.go new file mode 100644 index 00000000..51ed0eb0 --- /dev/null +++ b/tscap/auth/auth_test.go @@ -0,0 +1,219 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMatchesAction(t *testing.T) { + cases := []struct { + name string + granted []string + required string + want bool + }{ + {"wildcard grants all", []string{"*"}, "read", true}, + {"exact match", []string{"read"}, "read", true}, + {"no match", []string{"write"}, "read", false}, + {"multiple grants with match", []string{"write", "read"}, "read", true}, + {"multiple grants no match", []string{"write", "delete"}, "read", false}, + {"empty grants", []string{}, "read", false}, + {"wildcard in list", []string{"write", "*"}, "read", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := matchesAction(tc.granted, tc.required) + if got != tc.want { + t.Errorf("matchesAction(%v, %q) = %v, want %v", + tc.granted, tc.required, got, tc.want) + } + }) + } +} + +func TestMatchesResource(t *testing.T) { + cases := []struct { + name string + granted []string + required string + want bool + }{ + {"wildcard grants all", []string{"*"}, "items/123", true}, + {"exact match", []string{"items/123"}, "items/123", true}, + {"no match", []string{"items/456"}, "items/123", false}, + {"pattern exact match", []string{"items/*"}, "items/*", true}, + {"multiple grants with match", []string{"users/*", "items/*"}, "items/*", true}, + {"empty grants", []string{}, "items/123", false}, + {"wildcard in list", []string{"users/*", "*"}, "items/123", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := matchesResource(tc.granted, tc.required) + if got != tc.want { + t.Errorf("matchesResource(%v, %q) = %v, want %v", + tc.granted, tc.required, got, tc.want) + } + }) + } +} + +func TestCheck(t *testing.T) { + cases := []struct { + name string + caps Capabilities + req Requirement + wantOK bool + wantStatus int + }{ + { + name: "matching capability", + caps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"read"}, Resources: []string{"*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "read", Resource: "items/123"}, + wantOK: true, + }, + { + name: "wildcard action", + caps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"*"}, Resources: []string{"items/*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "write", Resource: "items/*"}, + wantOK: true, + }, + { + name: "missing capability", + caps: Capabilities{ + "example.com/cap/other": []Grant{ + {Action: []string{"*"}, Resources: []string{"*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "read", Resource: "*"}, + wantOK: false, + wantStatus: http.StatusForbidden, + }, + { + name: "wrong action", + caps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"read"}, Resources: []string{"*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "write", Resource: "*"}, + wantOK: false, + wantStatus: http.StatusForbidden, + }, + { + name: "wrong resource", + caps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"*"}, Resources: []string{"users/*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "read", Resource: "items/*"}, + wantOK: false, + wantStatus: http.StatusForbidden, + }, + { + name: "multiple grants first matches", + caps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"read"}, Resources: []string{"items/*"}}, + {Action: []string{"write"}, Resources: []string{"users/*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "read", Resource: "items/*"}, + wantOK: true, + }, + { + name: "multiple grants second matches", + caps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"read"}, Resources: []string{"items/*"}}, + {Action: []string{"write"}, Resources: []string{"users/*"}}, + }, + }, + req: Requirement{Capability: "example.com/cap/app", Action: "write", Resource: "users/*"}, + wantOK: true, + }, + { + name: "empty capabilities", + caps: Capabilities{}, + req: Requirement{Capability: "example.com/cap/app", Action: "read", Resource: "*"}, + wantOK: false, + wantStatus: http.StatusForbidden, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + got := Check(w, tc.caps, tc.req) + if got != tc.wantOK { + t.Errorf("Check() = %v, want %v", got, tc.wantOK) + } + if !tc.wantOK && w.Code != tc.wantStatus { + t.Errorf("Check() status = %d, want %d", w.Code, tc.wantStatus) + } + }) + } +} + +func TestParseCapabilities(t *testing.T) { + cases := []struct { + name string + header string + wantOK bool + wantStatus int + wantCaps Capabilities + }{ + { + name: "valid capabilities", + header: `{"example.com/cap/app":[{"action":["read"],"resources":["*"]}]}`, + wantOK: true, + wantCaps: Capabilities{ + "example.com/cap/app": []Grant{ + {Action: []string{"read"}, Resources: []string{"*"}}, + }, + }, + }, + { + name: "missing header", + header: "", + wantOK: false, + wantStatus: http.StatusUnauthorized, + }, + { + name: "invalid json", + header: "not json", + wantOK: false, + wantStatus: http.StatusUnauthorized, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tc.header != "" { + req.Header.Set(Header, tc.header) + } + w := httptest.NewRecorder() + + caps, ok := ParseCapabilities(w, req) + if ok != tc.wantOK { + t.Errorf("ParseCapabilities() ok = %v, want %v", ok, tc.wantOK) + } + if !tc.wantOK && w.Code != tc.wantStatus { + t.Errorf("ParseCapabilities() status = %d, want %d", w.Code, tc.wantStatus) + } + if tc.wantOK { + if len(caps) != len(tc.wantCaps) { + t.Errorf("ParseCapabilities() caps length = %d, want %d", len(caps), len(tc.wantCaps)) + } + } + }) + } +} diff --git a/tscap/dsl/tscap.go b/tscap/dsl/tscap.go new file mode 100644 index 00000000..87c6a764 --- /dev/null +++ b/tscap/dsl/tscap.go @@ -0,0 +1,65 @@ +package dsl + +import ( + "goa.design/goa/v3/eval" + goaexpr "goa.design/goa/v3/expr" + "goa.design/plugins/v3/tscap" + "goa.design/plugins/v3/tscap/auth" +) + +// Require declares that the method requires a Tailscale app capability +// with the specified action and resource. +func Require(capability, action, resource string) { + gate := get() + if gate == nil { + return + } + + if capability == "" { + eval.ReportError("capability name cannot be empty in Require") + return + } + if action == "" { + eval.ReportError("action cannot be empty in Require") + return + } + if resource == "" { + eval.ReportError("resource cannot be empty in Require") + return + } + + gate.Requirement = &auth.Requirement{ + Capability: capability, + Action: action, + Resource: resource, + } +} + +// AllowAnonymous marks the method as not requiring any capability check. +func AllowAnonymous() { + gate := get() + if gate == nil { + return + } + gate.AllowAnonymous = true +} + +func get() *auth.Gate { + m, ok := eval.Current().(*goaexpr.MethodExpr) + if !ok { + eval.IncompatibleDSL() + return nil + } + + if _, exists := tscap.MethodGates[m.Service.Name]; !exists { + tscap.MethodGates[m.Service.Name] = make(map[string]*auth.Gate) + } + + if _, exists := tscap.MethodGates[m.Service.Name][m.Name]; !exists { + tscap.MethodGates[m.Service.Name][m.Name] = &auth.Gate{ + MethodName: m.Name, + } + } + + return tscap.MethodGates[m.Service.Name][m.Name] +} diff --git a/tscap/example/cmd/example/main.go b/tscap/example/cmd/example/main.go new file mode 100644 index 00000000..89d8762c --- /dev/null +++ b/tscap/example/cmd/example/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + goahttp "goa.design/goa/v3/http" + "goa.design/plugins/v3/tscap/example" + tscapsvc "goa.design/plugins/v3/tscap/example/gen/tscap" + tscapsvr "goa.design/plugins/v3/tscap/example/gen/http/tscap/server" +) + +func main() { + var ( + addr = flag.String("addr", ":8080", "HTTP listen address") + debug = flag.Bool("debug", false, "print all request headers") + ) + flag.Parse() + + logger := log.New(os.Stderr, "[tscap] ", log.Ltime) + + // Create service + svc := example.NewTscap(logger) + endpoints := tscapsvc.NewEndpoints(svc) + + // Create transport + mux := goahttp.NewMuxer() + dec := goahttp.RequestDecoder + enc := goahttp.ResponseEncoder + svr := tscapsvr.New(endpoints, mux, dec, enc, nil, nil) + tscapsvr.Mount(mux, svr) + + // Wrap mux with logging middleware + handler := loggingMiddleware(logger, *debug, mux) + + // Create HTTP server + httpServer := &http.Server{ + Addr: *addr, + Handler: handler, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + } + + // Start server + go func() { + logger.Printf("listening on %s", *addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal(err) + } + }() + + // Wait for interrupt + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + // Graceful shutdown + logger.Print("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := httpServer.Shutdown(ctx); err != nil { + logger.Fatal(err) + } + fmt.Println("done") +} + +func loggingMiddleware(logger *log.Logger, debug bool, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if debug { + logger.Printf("%s %s headers:", r.Method, r.URL.Path) + for name, values := range r.Header { + for _, v := range values { + logger.Printf(" %s: %s", name, v) + } + } + } else { + caps := r.Header.Get("Tailscale-App-Capabilities") + if caps == "" { + logger.Printf("%s %s - no caps header", r.Method, r.URL.Path) + } else { + preview := caps + if len(preview) > 100 { + preview = preview[:100] + "..." + } + logger.Printf("%s %s - caps: %s", r.Method, r.URL.Path, preview) + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/tscap/example/cmd/example/main_test.go b/tscap/example/cmd/example/main_test.go new file mode 100644 index 00000000..8701ffe1 --- /dev/null +++ b/tscap/example/cmd/example/main_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + goahttp "goa.design/goa/v3/http" + "goa.design/plugins/v3/tscap/example" + tscapsvc "goa.design/plugins/v3/tscap/example/gen/tscap" + tscapsvr "goa.design/plugins/v3/tscap/example/gen/http/tscap/server" +) + +func setupServer() http.Handler { + logger := log.New(os.Stderr, "[test] ", log.Ltime) + svc := example.NewTscap(logger) + endpoints := tscapsvc.NewEndpoints(svc) + mux := goahttp.NewMuxer() + dec := goahttp.RequestDecoder + enc := goahttp.ResponseEncoder + svr := tscapsvr.New(endpoints, mux, dec, enc, nil, nil) + tscapsvr.Mount(mux, svr) + return mux +} + +func makeCapHeader(caps map[string][]map[string][]string) string { + b, _ := json.Marshal(caps) + return string(b) +} + +func TestHealthAnonymous(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestListWithoutCaps(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("GET", "/items", nil) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", w.Code) + } +} + +func TestListWithReadCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("GET", "/items", nil) + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"read"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestListWithWildcardCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("GET", "/items", nil) + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"*"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} + +func TestListWithWrongCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("GET", "/items", nil) + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"write"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestListWithWrongCapability(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("GET", "/items", nil) + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/other": { + {"action": {"read"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestCreateWithWriteCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("POST", "/items", strings.NewReader(`{"name":"test"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"write"}, "resources": {"items/*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + body, _ := io.ReadAll(w.Body) + t.Errorf("expected 201, got %d: %s", w.Code, string(body)) + } +} + +func TestCreateWithReadCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("POST", "/items", strings.NewReader(`{"name":"test"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"read"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} + +func TestAdminWithAdminCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("DELETE", "/items/123", nil) + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"admin"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + body, _ := io.ReadAll(w.Body) + t.Errorf("expected 204, got %d: %s", w.Code, string(body)) + } +} + +func TestAdminWithReadCap(t *testing.T) { + srv := setupServer() + req := httptest.NewRequest("DELETE", "/items/123", nil) + req.Header.Set("Tailscale-App-Capabilities", makeCapHeader(map[string][]map[string][]string{ + "example.com/cap/tscap": { + {"action": {"read"}, "resources": {"*"}}, + }, + })) + w := httptest.NewRecorder() + srv.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403, got %d", w.Code) + } +} diff --git a/tscap/example/design/design.go b/tscap/example/design/design.go new file mode 100644 index 00000000..f30f5e0c --- /dev/null +++ b/tscap/example/design/design.go @@ -0,0 +1,66 @@ +package design + +import ( + . "goa.design/goa/v3/dsl" + tscap "goa.design/plugins/v3/tscap/dsl" +) + +var _ = API("tscap_example", func() { + Title("Tailscale Capabilities Example API") + Description("An example API demonstrating Tailscale app capabilities authorization") +}) + +var _ = Service("tscap", func() { + Description("A service demonstrating Tailscale app capabilities") + + Method("list", func() { + Description("List items - requires read capability") + tscap.Require("example.com/cap/tscap", "read", "*") + Result(ArrayOf(String)) + HTTP(func() { + GET("/items") + Response(StatusOK) + }) + }) + + Method("create", func() { + Description("Create an item - requires write capability") + tscap.Require("example.com/cap/tscap", "write", "items/*") + Payload(func() { + Attribute("name", String, "Item name") + Required("name") + }) + Result(String) + HTTP(func() { + POST("/items") + Response(StatusCreated) + }) + }) + + Method("admin", func() { + Description("Admin action - requires admin capability") + tscap.Require("example.com/cap/tscap", "admin", "*") + Payload(func() { + Attribute("id", String, "Item ID") + Required("id") + }) + HTTP(func() { + DELETE("/items/{id}") + Response(StatusNoContent) + }) + }) + + Method("health", func() { + Description("Health check - no capability required") + tscap.AllowAnonymous() + Result(String) + HTTP(func() { + GET("/health") + Response(StatusOK) + }) + }) +}) + +var _ = Service("openapi", func() { + Files("/openapi.json", "gen/http/openapi3.json") +}) diff --git a/tscap/example/gen/http/cli/tscap_example/cli.go b/tscap/example/gen/http/cli/tscap_example/cli.go new file mode 100644 index 00000000..0bee47d3 --- /dev/null +++ b/tscap/example/gen/http/cli/tscap_example/cli.go @@ -0,0 +1,235 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap_example HTTP client CLI support package +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package cli + +import ( + "flag" + "fmt" + "net/http" + "os" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + tscapc "goa.design/plugins/v3/tscap/example/gen/http/tscap/client" +) + +// UsageCommands returns the set of commands and sub-commands using the format +// +// command (subcommand1|subcommand2|...) +func UsageCommands() []string { + return []string{ + "tscap (list|create|admin|health)", + } +} + +// UsageExamples produces an example of a valid invocation of the CLI tool. +func UsageExamples() string { + return os.Args[0] + " " + "tscap list" + "\n" + + "" +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + scheme, host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restore bool, +) (goa.Endpoint, any, error) { + var ( + tscapFlags = flag.NewFlagSet("tscap", flag.ContinueOnError) + + tscapListFlags = flag.NewFlagSet("list", flag.ExitOnError) + + tscapCreateFlags = flag.NewFlagSet("create", flag.ExitOnError) + tscapCreateBodyFlag = tscapCreateFlags.String("body", "REQUIRED", "") + + tscapAdminFlags = flag.NewFlagSet("admin", flag.ExitOnError) + tscapAdminIDFlag = tscapAdminFlags.String("id", "REQUIRED", "Item ID") + + tscapHealthFlags = flag.NewFlagSet("health", flag.ExitOnError) + ) + tscapFlags.Usage = tscapUsage + tscapListFlags.Usage = tscapListUsage + tscapCreateFlags.Usage = tscapCreateUsage + tscapAdminFlags.Usage = tscapAdminUsage + tscapHealthFlags.Usage = tscapHealthUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "tscap": + svcf = tscapFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "tscap": + switch epn { + case "list": + epf = tscapListFlags + + case "create": + epf = tscapCreateFlags + + case "admin": + epf = tscapAdminFlags + + case "health": + epf = tscapHealthFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "tscap": + c := tscapc.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "list": + endpoint = c.List() + case "create": + endpoint = c.Create() + data, err = tscapc.BuildCreatePayload(*tscapCreateBodyFlag) + case "admin": + endpoint = c.Admin() + data, err = tscapc.BuildAdminPayload(*tscapAdminIDFlag) + case "health": + endpoint = c.Health() + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + +// tscapUsage displays the usage of the tscap command and its subcommands. +func tscapUsage() { + fmt.Fprintln(os.Stderr, `A service demonstrating Tailscale app capabilities`) + fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] tscap COMMAND [flags]\n\n", os.Args[0]) + fmt.Fprintln(os.Stderr, "COMMAND:") + fmt.Fprintln(os.Stderr, ` list: List items - requires read capability`) + fmt.Fprintln(os.Stderr, ` create: Create an item - requires write capability`) + fmt.Fprintln(os.Stderr, ` admin: Admin action - requires admin capability`) + fmt.Fprintln(os.Stderr, ` health: Health check - no capability required`) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Additional help:") + fmt.Fprintf(os.Stderr, " %s tscap COMMAND --help\n", os.Args[0]) +} +func tscapListUsage() { + // Header with flags + fmt.Fprintf(os.Stderr, "%s [flags] tscap list", os.Args[0]) + fmt.Fprintln(os.Stderr) + + // Description + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, `List items - requires read capability`) + + // Flags list + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "tscap list") +} + +func tscapCreateUsage() { + // Header with flags + fmt.Fprintf(os.Stderr, "%s [flags] tscap create", os.Args[0]) + fmt.Fprint(os.Stderr, " -body JSON") + fmt.Fprintln(os.Stderr) + + // Description + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, `Create an item - requires write capability`) + + // Flags list + fmt.Fprintln(os.Stderr, ` -body JSON: `) + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "tscap create --body '{\n \"name\": \"Voluptatem similique dignissimos.\"\n }'") +} + +func tscapAdminUsage() { + // Header with flags + fmt.Fprintf(os.Stderr, "%s [flags] tscap admin", os.Args[0]) + fmt.Fprint(os.Stderr, " -id STRING") + fmt.Fprintln(os.Stderr) + + // Description + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, `Admin action - requires admin capability`) + + // Flags list + fmt.Fprintln(os.Stderr, ` -id STRING: Item ID`) + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "tscap admin --id \"Atque consequatur ex voluptas ducimus dolorum quo.\"") +} + +func tscapHealthUsage() { + // Header with flags + fmt.Fprintf(os.Stderr, "%s [flags] tscap health", os.Args[0]) + fmt.Fprintln(os.Stderr) + + // Description + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, `Health check - no capability required`) + + // Flags list + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "tscap health") +} diff --git a/tscap/example/gen/http/openapi.json b/tscap/example/gen/http/openapi.json new file mode 100644 index 00000000..74ebd995 --- /dev/null +++ b/tscap/example/gen/http/openapi.json @@ -0,0 +1 @@ +{"swagger":"2.0","info":{"title":"Tailscale Capabilities Example API","description":"An example API demonstrating Tailscale app capabilities authorization","version":"0.0.1"},"host":"localhost:80","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/health":{"get":{"tags":["tscap"],"summary":"health tscap","description":"Health check - no capability required","operationId":"tscap#health","responses":{"200":{"description":"OK response.","schema":{"type":"string"}}},"schemes":["http"]}},"/items":{"get":{"tags":["tscap"],"summary":"list tscap","description":"List items - requires read capability","operationId":"tscap#list","responses":{"200":{"description":"OK response.","schema":{"type":"array","items":{"type":"string","example":"Asperiores mollitia ad quos minima voluptatibus quia."}}}},"schemes":["http"]},"post":{"tags":["tscap"],"summary":"create tscap","description":"Create an item - requires write capability","operationId":"tscap#create","parameters":[{"name":"CreateRequestBody","in":"body","required":true,"schema":{"$ref":"#/definitions/TscapCreateRequestBody","required":["name"]}}],"responses":{"201":{"description":"Created response.","schema":{"type":"string"}}},"schemes":["http"]}},"/items/{id}":{"delete":{"tags":["tscap"],"summary":"admin tscap","description":"Admin action - requires admin capability","operationId":"tscap#admin","parameters":[{"name":"id","in":"path","description":"Item ID","required":true,"type":"string"}],"responses":{"204":{"description":"No Content response."}},"schemes":["http"]}},"/openapi.json":{"get":{"tags":["openapi"],"summary":"Download gen/http/openapi3.json","operationId":"openapi#/openapi.json","responses":{"200":{"description":"File downloaded","schema":{"type":"file"}}},"schemes":["http"]}}},"definitions":{"TscapCreateRequestBody":{"title":"TscapCreateRequestBody","type":"object","properties":{"name":{"type":"string","description":"Item name","example":"Vero beatae quo ad autem."}},"example":{"name":"Fugiat aut aut et."},"required":["name"]}}} \ No newline at end of file diff --git a/tscap/example/gen/http/openapi.yaml b/tscap/example/gen/http/openapi.yaml new file mode 100644 index 00000000..13ed4720 --- /dev/null +++ b/tscap/example/gen/http/openapi.yaml @@ -0,0 +1,111 @@ +swagger: "2.0" +info: + title: Tailscale Capabilities Example API + description: An example API demonstrating Tailscale app capabilities authorization + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /health: + get: + tags: + - tscap + summary: health tscap + description: Health check - no capability required + operationId: tscap#health + responses: + "200": + description: OK response. + schema: + type: string + schemes: + - http + /items: + get: + tags: + - tscap + summary: list tscap + description: List items - requires read capability + operationId: tscap#list + responses: + "200": + description: OK response. + schema: + type: array + items: + type: string + example: Asperiores mollitia ad quos minima voluptatibus quia. + schemes: + - http + post: + tags: + - tscap + summary: create tscap + description: Create an item - requires write capability + operationId: tscap#create + parameters: + - name: CreateRequestBody + in: body + required: true + schema: + $ref: '#/definitions/TscapCreateRequestBody' + required: + - name + responses: + "201": + description: Created response. + schema: + type: string + schemes: + - http + /items/{id}: + delete: + tags: + - tscap + summary: admin tscap + description: Admin action - requires admin capability + operationId: tscap#admin + parameters: + - name: id + in: path + description: Item ID + required: true + type: string + responses: + "204": + description: No Content response. + schemes: + - http + /openapi.json: + get: + tags: + - openapi + summary: Download gen/http/openapi3.json + operationId: openapi#/openapi.json + responses: + "200": + description: File downloaded + schema: + type: file + schemes: + - http +definitions: + TscapCreateRequestBody: + title: TscapCreateRequestBody + type: object + properties: + name: + type: string + description: Item name + example: Vero beatae quo ad autem. + example: + name: Fugiat aut aut et. + required: + - name diff --git a/tscap/example/gen/http/openapi/client/client.go b/tscap/example/gen/http/openapi/client/client.go new file mode 100644 index 00000000..6f13d6ad --- /dev/null +++ b/tscap/example/gen/http/openapi/client/client.go @@ -0,0 +1,45 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi client HTTP transport +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client + +import ( + "net/http" + + goahttp "goa.design/goa/v3/http" +) + +// Client lists the openapi service endpoint HTTP clients. +type Client struct { + // RestoreResponseBody controls whether the response bodies are reset after + // decoding so they can be read again. + RestoreResponseBody bool + + scheme string + host string + encoder func(*http.Request) goahttp.Encoder + decoder func(*http.Response) goahttp.Decoder +} + +// NewClient instantiates HTTP clients for all the openapi service servers. +func NewClient( + scheme string, + host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restoreBody bool, +) *Client { + return &Client{ + RestoreResponseBody: restoreBody, + scheme: scheme, + host: host, + decoder: dec, + encoder: enc, + } +} diff --git a/tscap/example/gen/http/openapi/client/encode_decode.go b/tscap/example/gen/http/openapi/client/encode_decode.go new file mode 100644 index 00000000..1768ff41 --- /dev/null +++ b/tscap/example/gen/http/openapi/client/encode_decode.go @@ -0,0 +1,9 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi HTTP client encoders and decoders +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client diff --git a/tscap/example/gen/http/openapi/client/paths.go b/tscap/example/gen/http/openapi/client/paths.go new file mode 100644 index 00000000..1c2fc25f --- /dev/null +++ b/tscap/example/gen/http/openapi/client/paths.go @@ -0,0 +1,9 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// HTTP request path constructors for the openapi service. +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client diff --git a/tscap/example/gen/http/openapi/client/types.go b/tscap/example/gen/http/openapi/client/types.go new file mode 100644 index 00000000..8d44191a --- /dev/null +++ b/tscap/example/gen/http/openapi/client/types.go @@ -0,0 +1,9 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi HTTP client types +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client diff --git a/tscap/example/gen/http/openapi/server/paths.go b/tscap/example/gen/http/openapi/server/paths.go new file mode 100644 index 00000000..21b437d5 --- /dev/null +++ b/tscap/example/gen/http/openapi/server/paths.go @@ -0,0 +1,9 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// HTTP request path constructors for the openapi service. +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server diff --git a/tscap/example/gen/http/openapi/server/server.go b/tscap/example/gen/http/openapi/server/server.go new file mode 100644 index 00000000..89799f62 --- /dev/null +++ b/tscap/example/gen/http/openapi/server/server.go @@ -0,0 +1,111 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi HTTP server +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server + +import ( + "context" + "net/http" + "path" + + goahttp "goa.design/goa/v3/http" + openapi "goa.design/plugins/v3/tscap/example/gen/openapi" +) + +// Server lists the openapi service endpoint HTTP handlers. +type Server struct { + Mounts []*MountPoint + GenHTTPOpenapi3JSON http.Handler +} + +// MountPoint holds information about the mounted endpoints. +type MountPoint struct { + // Method is the name of the service method served by the mounted HTTP handler. + Method string + // Verb is the HTTP method used to match requests to the mounted handler. + Verb string + // Pattern is the HTTP request path pattern used to match requests to the + // mounted handler. + Pattern string +} + +// New instantiates HTTP handlers for all the openapi service endpoints using +// the provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *openapi.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemGenHTTPOpenapi3JSON http.FileSystem, +) *Server { + if fileSystemGenHTTPOpenapi3JSON == nil { + fileSystemGenHTTPOpenapi3JSON = http.Dir(".") + } + fileSystemGenHTTPOpenapi3JSON = appendPrefix(fileSystemGenHTTPOpenapi3JSON, "/gen/http") + return &Server{ + Mounts: []*MountPoint{ + {"Serve gen/http/openapi3.json", "GET", "/openapi.json"}, + }, + GenHTTPOpenapi3JSON: http.FileServer(fileSystemGenHTTPOpenapi3JSON), + } +} + +// Service returns the name of the service served. +func (s *Server) Service() string { return "openapi" } + +// Use wraps the server handlers with the given middleware. +func (s *Server) Use(m func(http.Handler) http.Handler) { +} + +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return openapi.MethodNames[:] } + +// Mount configures the mux to serve the openapi endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountGenHTTPOpenapi3JSON(mux, h.GenHTTPOpenapi3JSON) +} + +// Mount configures the mux to serve the openapi endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} + +// appendFS is a custom implementation of fs.FS that appends a specified prefix +// to the file paths before delegating the Open call to the underlying fs.FS. +type appendFS struct { + prefix string + fs http.FileSystem +} + +// Open opens the named file, appending the prefix to the file path before +// passing it to the underlying fs.FS. +func (s appendFS) Open(name string) (http.File, error) { + switch name { + case "/openapi.json": + name = "/openapi3.json" + } + return s.fs.Open(path.Join(s.prefix, name)) +} + +// appendPrefix returns a new fs.FS that appends the specified prefix to file paths +// before delegating to the provided embed.FS. +func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { + return appendFS{prefix: prefix, fs: fsys} +} + +// MountGenHTTPOpenapi3JSON configures the mux to serve GET request made to +// "/openapi.json". +func MountGenHTTPOpenapi3JSON(mux goahttp.Muxer, h http.Handler) { + mux.Handle("GET", "/openapi.json", h.ServeHTTP) +} diff --git a/tscap/example/gen/http/openapi/server/types.go b/tscap/example/gen/http/openapi/server/types.go new file mode 100644 index 00000000..af2a84e6 --- /dev/null +++ b/tscap/example/gen/http/openapi/server/types.go @@ -0,0 +1,9 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi HTTP server types +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server diff --git a/tscap/example/gen/http/openapi3.json b/tscap/example/gen/http/openapi3.json new file mode 100644 index 00000000..444267f5 --- /dev/null +++ b/tscap/example/gen/http/openapi3.json @@ -0,0 +1 @@ +{"openapi":"3.0.3","info":{"title":"Tailscale Capabilities Example API","description":"An example API demonstrating Tailscale app capabilities authorization","version":"0.0.1"},"servers":[{"url":"http://localhost:80","description":"Default server for tscap_example"}],"paths":{"/health":{"get":{"tags":["tscap"],"summary":"health tscap","description":"Health check - no capability required","operationId":"tscap#health","responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"type":"string","example":"Consequuntur numquam dolorem aut."},"example":"Praesentium voluptas est dicta tempora libero."}}}}}},"/items":{"get":{"tags":["tscap"],"summary":"list tscap","description":"List items - requires read capability","operationId":"tscap#list","responses":{"200":{"description":"OK response.","content":{"application/json":{"schema":{"type":"array","items":{"type":"string","example":"Molestiae similique sed nisi numquam."},"example":["Id labore.","Earum vitae dolores commodi dignissimos dolor ut.","Sed libero voluptatum fuga.","Qui consequatur et est."]},"example":["Dicta aut aspernatur vel.","Pariatur fuga ut molestias aut."]}}}}},"post":{"tags":["tscap"],"summary":"create tscap","description":"Create an item - requires write capability","operationId":"tscap#create","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRequestBody"},"example":{"name":"Voluptatem similique dignissimos."}}}},"responses":{"201":{"description":"Created response.","content":{"application/json":{"schema":{"type":"string","example":"Nihil quo quam et dignissimos."},"example":"Voluptas dicta quibusdam vel enim."}}}}}},"/items/{id}":{"delete":{"tags":["tscap"],"summary":"admin tscap","description":"Admin action - requires admin capability","operationId":"tscap#admin","parameters":[{"name":"id","in":"path","description":"Item ID","required":true,"schema":{"type":"string","description":"Item ID","example":"Magnam explicabo provident ut ipsam."},"example":"Placeat et et."}],"responses":{"204":{"description":"No Content response."}}}},"/openapi.json":{"get":{"tags":["openapi"],"summary":"Download gen/http/openapi3.json","operationId":"openapi#/openapi.json","responses":{"200":{"description":"File downloaded"}}}}},"components":{"schemas":{"CreateRequestBody":{"type":"object","properties":{"name":{"type":"string","description":"Item name","example":"Omnis in."}},"example":{"name":"Ea vel."},"required":["name"]}}},"tags":[{"name":"openapi"},{"name":"tscap","description":"A service demonstrating Tailscale app capabilities"}]} \ No newline at end of file diff --git a/tscap/example/gen/http/openapi3.yaml b/tscap/example/gen/http/openapi3.yaml new file mode 100644 index 00000000..d5752bf6 --- /dev/null +++ b/tscap/example/gen/http/openapi3.yaml @@ -0,0 +1,119 @@ +openapi: 3.0.3 +info: + title: Tailscale Capabilities Example API + description: An example API demonstrating Tailscale app capabilities authorization + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for tscap_example +paths: + /health: + get: + tags: + - tscap + summary: health tscap + description: Health check - no capability required + operationId: tscap#health + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: Consequuntur numquam dolorem aut. + example: Praesentium voluptas est dicta tempora libero. + /items: + get: + tags: + - tscap + summary: list tscap + description: List items - requires read capability + operationId: tscap#list + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: array + items: + type: string + example: Molestiae similique sed nisi numquam. + example: + - Id labore. + - Earum vitae dolores commodi dignissimos dolor ut. + - Sed libero voluptatum fuga. + - Qui consequatur et est. + example: + - Dicta aut aspernatur vel. + - Pariatur fuga ut molestias aut. + post: + tags: + - tscap + summary: create tscap + description: Create an item - requires write capability + operationId: tscap#create + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRequestBody' + example: + name: Voluptatem similique dignissimos. + responses: + "201": + description: Created response. + content: + application/json: + schema: + type: string + example: Nihil quo quam et dignissimos. + example: Voluptas dicta quibusdam vel enim. + /items/{id}: + delete: + tags: + - tscap + summary: admin tscap + description: Admin action - requires admin capability + operationId: tscap#admin + parameters: + - name: id + in: path + description: Item ID + required: true + schema: + type: string + description: Item ID + example: Magnam explicabo provident ut ipsam. + example: Placeat et et. + responses: + "204": + description: No Content response. + /openapi.json: + get: + tags: + - openapi + summary: Download gen/http/openapi3.json + operationId: openapi#/openapi.json + responses: + "200": + description: File downloaded +components: + schemas: + CreateRequestBody: + type: object + properties: + name: + type: string + description: Item name + example: Omnis in. + example: + name: Ea vel. + required: + - name +tags: + - name: openapi + - name: tscap + description: A service demonstrating Tailscale app capabilities diff --git a/tscap/example/gen/http/tscap/client/cli.go b/tscap/example/gen/http/tscap/client/cli.go new file mode 100644 index 00000000..c6219a32 --- /dev/null +++ b/tscap/example/gen/http/tscap/client/cli.go @@ -0,0 +1,47 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap HTTP client CLI support package +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client + +import ( + "encoding/json" + "fmt" + + tscap "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +// BuildCreatePayload builds the payload for the tscap create endpoint from CLI +// flags. +func BuildCreatePayload(tscapCreateBody string) (*tscap.CreatePayload, error) { + var err error + var body CreateRequestBody + { + err = json.Unmarshal([]byte(tscapCreateBody), &body) + if err != nil { + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"name\": \"Voluptatem similique dignissimos.\"\n }'") + } + } + v := &tscap.CreatePayload{ + Name: body.Name, + } + + return v, nil +} + +// BuildAdminPayload builds the payload for the tscap admin endpoint from CLI +// flags. +func BuildAdminPayload(tscapAdminID string) (*tscap.AdminPayload, error) { + var id string + { + id = tscapAdminID + } + v := &tscap.AdminPayload{} + v.ID = id + + return v, nil +} diff --git a/tscap/example/gen/http/tscap/client/client.go b/tscap/example/gen/http/tscap/client/client.go new file mode 100644 index 00000000..50027cef --- /dev/null +++ b/tscap/example/gen/http/tscap/client/client.go @@ -0,0 +1,144 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap client HTTP transport +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client + +import ( + "context" + "net/http" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// Client lists the tscap service endpoint HTTP clients. +type Client struct { + // List Doer is the HTTP client used to make requests to the list endpoint. + ListDoer goahttp.Doer + + // Create Doer is the HTTP client used to make requests to the create endpoint. + CreateDoer goahttp.Doer + + // Admin Doer is the HTTP client used to make requests to the admin endpoint. + AdminDoer goahttp.Doer + + // Health Doer is the HTTP client used to make requests to the health endpoint. + HealthDoer goahttp.Doer + + // RestoreResponseBody controls whether the response bodies are reset after + // decoding so they can be read again. + RestoreResponseBody bool + + scheme string + host string + encoder func(*http.Request) goahttp.Encoder + decoder func(*http.Response) goahttp.Decoder +} + +// NewClient instantiates HTTP clients for all the tscap service servers. +func NewClient( + scheme string, + host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restoreBody bool, +) *Client { + return &Client{ + ListDoer: doer, + CreateDoer: doer, + AdminDoer: doer, + HealthDoer: doer, + RestoreResponseBody: restoreBody, + scheme: scheme, + host: host, + decoder: dec, + encoder: enc, + } +} + +// List returns an endpoint that makes HTTP requests to the tscap service list +// server. +func (c *Client) List() goa.Endpoint { + var ( + decodeResponse = DecodeListResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildListRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.ListDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("tscap", "list", err) + } + return decodeResponse(resp) + } +} + +// Create returns an endpoint that makes HTTP requests to the tscap service +// create server. +func (c *Client) Create() goa.Endpoint { + var ( + encodeRequest = EncodeCreateRequest(c.encoder) + decodeResponse = DecodeCreateResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildCreateRequest(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.CreateDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("tscap", "create", err) + } + return decodeResponse(resp) + } +} + +// Admin returns an endpoint that makes HTTP requests to the tscap service +// admin server. +func (c *Client) Admin() goa.Endpoint { + var ( + decodeResponse = DecodeAdminResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildAdminRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.AdminDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("tscap", "admin", err) + } + return decodeResponse(resp) + } +} + +// Health returns an endpoint that makes HTTP requests to the tscap service +// health server. +func (c *Client) Health() goa.Endpoint { + var ( + decodeResponse = DecodeHealthResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildHealthRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.HealthDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("tscap", "health", err) + } + return decodeResponse(resp) + } +} diff --git a/tscap/example/gen/http/tscap/client/encode_decode.go b/tscap/example/gen/http/tscap/client/encode_decode.go new file mode 100644 index 00000000..cd742de4 --- /dev/null +++ b/tscap/example/gen/http/tscap/client/encode_decode.go @@ -0,0 +1,238 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap HTTP client encoders and decoders +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + + goahttp "goa.design/goa/v3/http" + tscap "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +// BuildListRequest instantiates a HTTP request object with method and path set +// to call the "tscap" service "list" endpoint +func (c *Client) BuildListRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: ListTscapPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("tscap", "list", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// DecodeListResponse returns a decoder for responses returned by the tscap +// list endpoint. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeListResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body []string + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("tscap", "list", err) + } + return body, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("tscap", "list", resp.StatusCode, string(body)) + } + } +} + +// BuildCreateRequest instantiates a HTTP request object with method and path +// set to call the "tscap" service "create" endpoint +func (c *Client) BuildCreateRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CreateTscapPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("tscap", "create", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeCreateRequest returns an encoder for requests sent to the tscap create +// server. +func EncodeCreateRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*tscap.CreatePayload) + if !ok { + return goahttp.ErrInvalidType("tscap", "create", "*tscap.CreatePayload", v) + } + body := NewCreateRequestBody(p) + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("tscap", "create", err) + } + return nil + } +} + +// DecodeCreateResponse returns a decoder for responses returned by the tscap +// create endpoint. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeCreateResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusCreated: + var ( + body string + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("tscap", "create", err) + } + return body, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("tscap", "create", resp.StatusCode, string(body)) + } + } +} + +// BuildAdminRequest instantiates a HTTP request object with method and path +// set to call the "tscap" service "admin" endpoint +func (c *Client) BuildAdminRequest(ctx context.Context, v any) (*http.Request, error) { + var ( + id string + ) + { + p, ok := v.(*tscap.AdminPayload) + if !ok { + return nil, goahttp.ErrInvalidType("tscap", "admin", "*tscap.AdminPayload", v) + } + id = p.ID + } + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: AdminTscapPath(id)} + req, err := http.NewRequest("DELETE", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("tscap", "admin", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// DecodeAdminResponse returns a decoder for responses returned by the tscap +// admin endpoint. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeAdminResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusNoContent: + return nil, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("tscap", "admin", resp.StatusCode, string(body)) + } + } +} + +// BuildHealthRequest instantiates a HTTP request object with method and path +// set to call the "tscap" service "health" endpoint +func (c *Client) BuildHealthRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: HealthTscapPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("tscap", "health", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// DecodeHealthResponse returns a decoder for responses returned by the tscap +// health endpoint. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeHealthResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + if restoreBody { + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer resp.Body.Close() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body string + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("tscap", "health", err) + } + return body, nil + default: + body, _ := io.ReadAll(resp.Body) + return nil, goahttp.ErrInvalidResponse("tscap", "health", resp.StatusCode, string(body)) + } + } +} diff --git a/tscap/example/gen/http/tscap/client/paths.go b/tscap/example/gen/http/tscap/client/paths.go new file mode 100644 index 00000000..ce8633a8 --- /dev/null +++ b/tscap/example/gen/http/tscap/client/paths.go @@ -0,0 +1,33 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// HTTP request path constructors for the tscap service. +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client + +import ( + "fmt" +) + +// ListTscapPath returns the URL path to the tscap service list HTTP endpoint. +func ListTscapPath() string { + return "/items" +} + +// CreateTscapPath returns the URL path to the tscap service create HTTP endpoint. +func CreateTscapPath() string { + return "/items" +} + +// AdminTscapPath returns the URL path to the tscap service admin HTTP endpoint. +func AdminTscapPath(id string) string { + return fmt.Sprintf("/items/%v", id) +} + +// HealthTscapPath returns the URL path to the tscap service health HTTP endpoint. +func HealthTscapPath() string { + return "/health" +} diff --git a/tscap/example/gen/http/tscap/client/types.go b/tscap/example/gen/http/tscap/client/types.go new file mode 100644 index 00000000..cf48c3df --- /dev/null +++ b/tscap/example/gen/http/tscap/client/types.go @@ -0,0 +1,29 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap HTTP client types +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package client + +import ( + tscap "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +// CreateRequestBody is the type of the "tscap" service "create" endpoint HTTP +// request body. +type CreateRequestBody struct { + // Item name + Name string `form:"name" json:"name" xml:"name"` +} + +// NewCreateRequestBody builds the HTTP request body from the payload of the +// "create" endpoint of the "tscap" service. +func NewCreateRequestBody(p *tscap.CreatePayload) *CreateRequestBody { + body := &CreateRequestBody{ + Name: p.Name, + } + return body +} diff --git a/tscap/example/gen/http/tscap/server/encode_decode.go b/tscap/example/gen/http/tscap/server/encode_decode.go new file mode 100644 index 00000000..cd40935b --- /dev/null +++ b/tscap/example/gen/http/tscap/server/encode_decode.go @@ -0,0 +1,110 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap HTTP server encoders and decoders +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server + +import ( + "context" + "errors" + "io" + "net/http" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + tscap "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +// EncodeListResponse returns an encoder for responses returned by the tscap +// list endpoint. +func EncodeListResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.([]string) + enc := encoder(ctx, w) + body := res + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} + +// EncodeCreateResponse returns an encoder for responses returned by the tscap +// create endpoint. +func EncodeCreateResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(string) + enc := encoder(ctx, w) + body := res + w.WriteHeader(http.StatusCreated) + return enc.Encode(body) + } +} + +// DecodeCreateRequest returns a decoder for requests sent to the tscap create +// endpoint. +func DecodeCreateRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*tscap.CreatePayload, error) { + return func(r *http.Request) (*tscap.CreatePayload, error) { + var ( + body CreateRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return nil, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return nil, gerr + } + return nil, goa.DecodePayloadError(err.Error()) + } + err = ValidateCreateRequestBody(&body) + if err != nil { + return nil, err + } + payload := NewCreatePayload(&body) + + return payload, nil + } +} + +// EncodeAdminResponse returns an encoder for responses returned by the tscap +// admin endpoint. +func EncodeAdminResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + w.WriteHeader(http.StatusNoContent) + return nil + } +} + +// DecodeAdminRequest returns a decoder for requests sent to the tscap admin +// endpoint. +func DecodeAdminRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*tscap.AdminPayload, error) { + return func(r *http.Request) (*tscap.AdminPayload, error) { + var ( + id string + + params = mux.Vars(r) + ) + id = params["id"] + payload := NewAdminPayload(id) + + return payload, nil + } +} + +// EncodeHealthResponse returns an encoder for responses returned by the tscap +// health endpoint. +func EncodeHealthResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(string) + enc := encoder(ctx, w) + body := res + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/tscap/example/gen/http/tscap/server/paths.go b/tscap/example/gen/http/tscap/server/paths.go new file mode 100644 index 00000000..979a89ab --- /dev/null +++ b/tscap/example/gen/http/tscap/server/paths.go @@ -0,0 +1,33 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// HTTP request path constructors for the tscap service. +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server + +import ( + "fmt" +) + +// ListTscapPath returns the URL path to the tscap service list HTTP endpoint. +func ListTscapPath() string { + return "/items" +} + +// CreateTscapPath returns the URL path to the tscap service create HTTP endpoint. +func CreateTscapPath() string { + return "/items" +} + +// AdminTscapPath returns the URL path to the tscap service admin HTTP endpoint. +func AdminTscapPath(id string) string { + return fmt.Sprintf("/items/%v", id) +} + +// HealthTscapPath returns the URL path to the tscap service health HTTP endpoint. +func HealthTscapPath() string { + return "/health" +} diff --git a/tscap/example/gen/http/tscap/server/server.go b/tscap/example/gen/http/tscap/server/server.go new file mode 100644 index 00000000..3c514b19 --- /dev/null +++ b/tscap/example/gen/http/tscap/server/server.go @@ -0,0 +1,360 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap HTTP server +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server + +import ( + "context" + "net/http" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + "goa.design/plugins/v3/tscap/auth" + tscap "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +// Server lists the tscap service endpoint HTTP handlers. +type Server struct { + Mounts []*MountPoint + List http.Handler + Create http.Handler + Admin http.Handler + Health http.Handler +} + +// MountPoint holds information about the mounted endpoints. +type MountPoint struct { + // Method is the name of the service method served by the mounted HTTP handler. + Method string + // Verb is the HTTP method used to match requests to the mounted handler. + Verb string + // Pattern is the HTTP request path pattern used to match requests to the + // mounted handler. + Pattern string +} + +// New instantiates HTTP handlers for all the tscap service endpoints using the +// provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *tscap.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"List", "GET", "/items"}, + {"Create", "POST", "/items"}, + {"Admin", "DELETE", "/items/{id}"}, + {"Health", "GET", "/health"}, + }, + List: NewListHandler(e.List, mux, decoder, encoder, errhandler, formatter), + Create: NewCreateHandler(e.Create, mux, decoder, encoder, errhandler, formatter), + Admin: NewAdminHandler(e.Admin, mux, decoder, encoder, errhandler, formatter), + Health: NewHealthHandler(e.Health, mux, decoder, encoder, errhandler, formatter), + } +} + +// Service returns the name of the service served. +func (s *Server) Service() string { return "tscap" } + +// Use wraps the server handlers with the given middleware. +func (s *Server) Use(m func(http.Handler) http.Handler) { + s.List = m(s.List) + s.Create = m(s.Create) + s.Admin = m(s.Admin) + s.Health = m(s.Health) +} + +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return tscap.MethodNames[:] } + +// Mount configures the mux to serve the tscap endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountListHandler(mux, h.List) + MountCreateHandler(mux, h.Create) + MountAdminHandler(mux, h.Admin) + MountHealthHandler(mux, h.Health) +} + +// Mount configures the mux to serve the tscap endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} + +// MountListHandler configures the mux to serve the "tscap" service "list" +// endpoint. +func MountListHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/items", listTscap(f)) +} + +// NewListHandler creates a HTTP handler which loads the HTTP request and calls +// the "tscap" service "list" endpoint. +func NewListHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + encodeResponse = EncodeListResponse(encoder) + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "list") + ctx = context.WithValue(ctx, goa.ServiceKey, "tscap") + var err error + res, err := endpoint(ctx, nil) + if err != nil { + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + if errhandler != nil { + errhandler(ctx, w, err) + } + } + }) +} + +// MountCreateHandler configures the mux to serve the "tscap" service "create" +// endpoint. +func MountCreateHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("POST", "/items", createTscap(f)) +} + +// NewCreateHandler creates a HTTP handler which loads the HTTP request and +// calls the "tscap" service "create" endpoint. +func NewCreateHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + decodeRequest = DecodeCreateRequest(mux, decoder) + encodeResponse = EncodeCreateResponse(encoder) + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "create") + ctx = context.WithValue(ctx, goa.ServiceKey, "tscap") + payload, err := decodeRequest(r) + if err != nil { + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + res, err := endpoint(ctx, payload) + if err != nil { + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + if errhandler != nil { + errhandler(ctx, w, err) + } + } + }) +} + +// MountAdminHandler configures the mux to serve the "tscap" service "admin" +// endpoint. +func MountAdminHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("DELETE", "/items/{id}", adminTscap(f)) +} + +// NewAdminHandler creates a HTTP handler which loads the HTTP request and +// calls the "tscap" service "admin" endpoint. +func NewAdminHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + decodeRequest = DecodeAdminRequest(mux, decoder) + encodeResponse = EncodeAdminResponse(encoder) + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "admin") + ctx = context.WithValue(ctx, goa.ServiceKey, "tscap") + payload, err := decodeRequest(r) + if err != nil { + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + res, err := endpoint(ctx, payload) + if err != nil { + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + if errhandler != nil { + errhandler(ctx, w, err) + } + } + }) +} + +// MountHealthHandler configures the mux to serve the "tscap" service "health" +// endpoint. +func MountHealthHandler(mux goahttp.Muxer, h http.Handler) { + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/health", healthTscap(f)) +} + +// NewHealthHandler creates a HTTP handler which loads the HTTP request and +// calls the "tscap" service "health" endpoint. +func NewHealthHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) http.Handler { + var ( + encodeResponse = EncodeHealthResponse(encoder) + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "health") + ctx = context.WithValue(ctx, goa.ServiceKey, "tscap") + var err error + res, err := endpoint(ctx, nil) + if err != nil { + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeResponse(ctx, w, res); err != nil { + if errhandler != nil { + errhandler(ctx, w, err) + } + } + }) +} + +// for authorization based on Tailscale app capabilities +func listTscap(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + caps, ok := auth.ParseCapabilities(w, r) + if !ok { + return + } + req := auth.Requirement{ + Capability: `example.com/cap/tscap`, + Action: `read`, + Resource: `*`, + } + if !auth.Check(w, caps, req) { + return + } + handler(w, r) + } +} + +// for authorization based on Tailscale app capabilities +func createTscap(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + caps, ok := auth.ParseCapabilities(w, r) + if !ok { + return + } + req := auth.Requirement{ + Capability: `example.com/cap/tscap`, + Action: `write`, + Resource: `items/*`, + } + if !auth.Check(w, caps, req) { + return + } + handler(w, r) + } +} + +// for authorization based on Tailscale app capabilities +func adminTscap(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + caps, ok := auth.ParseCapabilities(w, r) + if !ok { + return + } + req := auth.Requirement{ + Capability: `example.com/cap/tscap`, + Action: `admin`, + Resource: `*`, + } + if !auth.Check(w, caps, req) { + return + } + handler(w, r) + } +} + +// for authorization based on Tailscale app capabilities +func healthTscap(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(auth.Header) == "" { + handler(w, r) + return + } + handler(w, r) + } +} diff --git a/tscap/example/gen/http/tscap/server/types.go b/tscap/example/gen/http/tscap/server/types.go new file mode 100644 index 00000000..de72da9c --- /dev/null +++ b/tscap/example/gen/http/tscap/server/types.go @@ -0,0 +1,46 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap HTTP server types +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package server + +import ( + goa "goa.design/goa/v3/pkg" + tscap "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +// CreateRequestBody is the type of the "tscap" service "create" endpoint HTTP +// request body. +type CreateRequestBody struct { + // Item name + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` +} + +// NewCreatePayload builds a tscap service create endpoint payload. +func NewCreatePayload(body *CreateRequestBody) *tscap.CreatePayload { + v := &tscap.CreatePayload{ + Name: *body.Name, + } + + return v +} + +// NewAdminPayload builds a tscap service admin endpoint payload. +func NewAdminPayload(id string) *tscap.AdminPayload { + v := &tscap.AdminPayload{} + v.ID = id + + return v +} + +// ValidateCreateRequestBody runs the validations defined on CreateRequestBody +func ValidateCreateRequestBody(body *CreateRequestBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + return +} diff --git a/tscap/example/gen/openapi/client.go b/tscap/example/gen/openapi/client.go new file mode 100644 index 00000000..60b27a78 --- /dev/null +++ b/tscap/example/gen/openapi/client.go @@ -0,0 +1,22 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi client +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package openapi + +import ( + goa "goa.design/goa/v3/pkg" +) + +// Client is the "openapi" service client. +type Client struct { +} + +// NewClient initializes a "openapi" service client given the endpoints. +func NewClient(goa.Endpoint) *Client { + return &Client{} +} diff --git a/tscap/example/gen/openapi/endpoints.go b/tscap/example/gen/openapi/endpoints.go new file mode 100644 index 00000000..82c5facc --- /dev/null +++ b/tscap/example/gen/openapi/endpoints.go @@ -0,0 +1,26 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi endpoints +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package openapi + +import ( + goa "goa.design/goa/v3/pkg" +) + +// Endpoints wraps the "openapi" service endpoints. +type Endpoints struct { +} + +// NewEndpoints wraps the methods of the "openapi" service with endpoints. +func NewEndpoints(s Service) *Endpoints { + return &Endpoints{} +} + +// Use applies the given middleware to all the "openapi" service endpoints. +func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { +} diff --git a/tscap/example/gen/openapi/service.go b/tscap/example/gen/openapi/service.go new file mode 100644 index 00000000..616e9be2 --- /dev/null +++ b/tscap/example/gen/openapi/service.go @@ -0,0 +1,29 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// openapi service +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package openapi + +// Service is the openapi service interface. +type Service interface { +} + +// APIName is the name of the API as defined in the design. +const APIName = "tscap_example" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "openapi" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [0]string{} diff --git a/tscap/example/gen/tscap/client.go b/tscap/example/gen/tscap/client.go new file mode 100644 index 00000000..76b43db4 --- /dev/null +++ b/tscap/example/gen/tscap/client.go @@ -0,0 +1,69 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap client +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package tscap + +import ( + "context" + + goa "goa.design/goa/v3/pkg" +) + +// Client is the "tscap" service client. +type Client struct { + ListEndpoint goa.Endpoint + CreateEndpoint goa.Endpoint + AdminEndpoint goa.Endpoint + HealthEndpoint goa.Endpoint +} + +// NewClient initializes a "tscap" service client given the endpoints. +func NewClient(list, create, admin, health goa.Endpoint) *Client { + return &Client{ + ListEndpoint: list, + CreateEndpoint: create, + AdminEndpoint: admin, + HealthEndpoint: health, + } +} + +// List calls the "list" endpoint of the "tscap" service. +func (c *Client) List(ctx context.Context) (res []string, err error) { + var ires any + ires, err = c.ListEndpoint(ctx, nil) + if err != nil { + return + } + return ires.([]string), nil +} + +// Create calls the "create" endpoint of the "tscap" service. +func (c *Client) Create(ctx context.Context, p *CreatePayload) (res string, err error) { + var ires any + ires, err = c.CreateEndpoint(ctx, p) + if err != nil { + return + } + return ires.(string), nil +} + +// Admin calls the "admin" endpoint of the "tscap" service. +func (c *Client) Admin(ctx context.Context, p *AdminPayload) (err error) { + _, err = c.AdminEndpoint(ctx, p) + return +} + +// Health calls the "health" endpoint of the "tscap" service. +func (c *Client) Health(ctx context.Context) (res string, err error) { + var ires any + ires, err = c.HealthEndpoint(ctx, nil) + if err != nil { + return + } + return ires.(string), nil +} diff --git a/tscap/example/gen/tscap/endpoints.go b/tscap/example/gen/tscap/endpoints.go new file mode 100644 index 00000000..10cee343 --- /dev/null +++ b/tscap/example/gen/tscap/endpoints.go @@ -0,0 +1,75 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap endpoints +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package tscap + +import ( + "context" + + goa "goa.design/goa/v3/pkg" +) + +// Endpoints wraps the "tscap" service endpoints. +type Endpoints struct { + List goa.Endpoint + Create goa.Endpoint + Admin goa.Endpoint + Health goa.Endpoint +} + +// NewEndpoints wraps the methods of the "tscap" service with endpoints. +func NewEndpoints(s Service) *Endpoints { + return &Endpoints{ + List: NewListEndpoint(s), + Create: NewCreateEndpoint(s), + Admin: NewAdminEndpoint(s), + Health: NewHealthEndpoint(s), + } +} + +// Use applies the given middleware to all the "tscap" service endpoints. +func (e *Endpoints) Use(m func(goa.Endpoint) goa.Endpoint) { + e.List = m(e.List) + e.Create = m(e.Create) + e.Admin = m(e.Admin) + e.Health = m(e.Health) +} + +// NewListEndpoint returns an endpoint function that calls the method "list" of +// service "tscap". +func NewListEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + return s.List(ctx) + } +} + +// NewCreateEndpoint returns an endpoint function that calls the method +// "create" of service "tscap". +func NewCreateEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + p := req.(*CreatePayload) + return s.Create(ctx, p) + } +} + +// NewAdminEndpoint returns an endpoint function that calls the method "admin" +// of service "tscap". +func NewAdminEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + p := req.(*AdminPayload) + return nil, s.Admin(ctx, p) + } +} + +// NewHealthEndpoint returns an endpoint function that calls the method +// "health" of service "tscap". +func NewHealthEndpoint(s Service) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + return s.Health(ctx) + } +} diff --git a/tscap/example/gen/tscap/service.go b/tscap/example/gen/tscap/service.go new file mode 100644 index 00000000..4da3e26a --- /dev/null +++ b/tscap/example/gen/tscap/service.go @@ -0,0 +1,53 @@ +// Code generated by goa v3.24.3, DO NOT EDIT. +// +// tscap service +// +// Command: +// $ goa gen goa.design/plugins/v3/tscap/example/design -o +// $(GOPATH)/src/goa.design/plugins/tscap//example + +package tscap + +import ( + "context" +) + +// A service demonstrating Tailscale app capabilities +type Service interface { + // List items - requires read capability + List(context.Context) (res []string, err error) + // Create an item - requires write capability + Create(context.Context, *CreatePayload) (res string, err error) + // Admin action - requires admin capability + Admin(context.Context, *AdminPayload) (err error) + // Health check - no capability required + Health(context.Context) (res string, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "tscap_example" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "tscap" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [4]string{"list", "create", "admin", "health"} + +// AdminPayload is the payload type of the tscap service admin method. +type AdminPayload struct { + // Item ID + ID string +} + +// CreatePayload is the payload type of the tscap service create method. +type CreatePayload struct { + // Item name + Name string +} diff --git a/tscap/example/service.go b/tscap/example/service.go new file mode 100644 index 00000000..184af7ab --- /dev/null +++ b/tscap/example/service.go @@ -0,0 +1,40 @@ +package example + +import ( + "context" + "log" + + tscapsvc "goa.design/plugins/v3/tscap/example/gen/tscap" +) + +type tscapService struct { + logger *log.Logger +} + +// NewTscap returns the tscap service implementation. +func NewTscap(logger *log.Logger) tscapsvc.Service { + return &tscapService{logger: logger} +} + +// List items - requires read capability. +func (s *tscapService) List(ctx context.Context) ([]string, error) { + s.logger.Print("list called") + return []string{"item1", "item2", "item3"}, nil +} + +// Create an item - requires write capability. +func (s *tscapService) Create(ctx context.Context, p *tscapsvc.CreatePayload) (string, error) { + s.logger.Printf("create called with name: %s", p.Name) + return "created: " + p.Name, nil +} + +// Admin action - requires admin capability. +func (s *tscapService) Admin(ctx context.Context, p *tscapsvc.AdminPayload) error { + s.logger.Printf("admin called with id: %s", p.ID) + return nil +} + +// Health check - no capability required. +func (s *tscapService) Health(ctx context.Context) (string, error) { + return "ok", nil +} diff --git a/tscap/generate.go b/tscap/generate.go new file mode 100644 index 00000000..752a3879 --- /dev/null +++ b/tscap/generate.go @@ -0,0 +1,118 @@ +package tscap + +import ( + "path/filepath" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + goahttp "goa.design/goa/v3/http/codegen" + "goa.design/plugins/v3/tscap/auth" +) + +// MethodGates stores the authorization gates for each method. +// Keys are service name -> method name -> gate configuration. +var MethodGates = make(map[string]map[string]*auth.Gate) + +func init() { + codegen.RegisterPlugin("tscap", "gen", nil, Generate) +} + +// Generate modifies the generated server code to add Tailscale capability +// authorization middleware. +func Generate(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + for _, file := range files { + if filepath.Base(file.Path) != "server.go" { + continue + } + + for _, s := range file.Section("server-handler") { + data, ok := s.Data.(*goahttp.EndpointData) + if !ok { + continue + } + + var gateDefined bool + var gate *auth.Gate + if serviceGates, ok := MethodGates[data.ServiceName]; ok { + if g, ok := serviceGates[data.Method.Name]; ok { + gateDefined = true + gate = g + } + } + + codegen.AddImport(file.SectionTemplates[0], + &codegen.ImportSpec{Path: "goa.design/plugins/v3/tscap/auth"}, + ) + + if gateDefined { + file.SectionTemplates = append(file.SectionTemplates, &codegen.SectionTemplate{ + Name: "tscap-middleware", + Source: definedGate, + Data: gate, + }) + } else { + file.SectionTemplates = append(file.SectionTemplates, &codegen.SectionTemplate{ + Name: "tscap-middleware", + Source: defaultGate, + Data: auth.Gate{ + MethodName: data.Method.Name, + }, + }) + } + + s.Source = strings.Replace( + s.Source, + `mux.Handle("{{ .Verb }}", "{{ .Path }}", f)`, + `mux.Handle("{{ .Verb }}", "{{ .Path }}", `+data.Method.Name+`Tscap(f))`, + 1, + ) + } + } + return files, nil +} + +const defaultGate = ` +{{ printf "for authorization based on Tailscale app capabilities" | comment }} +func {{ .MethodName }}Tscap(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.ParseCapabilities(w, r); !ok { + return + } + handler(w, r) + } +} +` + +const definedGate = ` +{{ printf "for authorization based on Tailscale app capabilities" | comment }} +func {{ .MethodName }}Tscap(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + {{- if .AllowAnonymous }} + if r.Header.Get(auth.Header) == "" { + handler(w, r) + return + } + {{- end }} + {{- if .Requirement }} + caps, ok := auth.ParseCapabilities(w, r) + if !ok { + return + } + req := auth.Requirement{ + Capability: ` + "`{{ .Requirement.Capability }}`" + `, + Action: ` + "`{{ .Requirement.Action }}`" + `, + Resource: ` + "`{{ .Requirement.Resource }}`" + `, + } + if !auth.Check(w, caps, req) { + return + } + {{- else if not .AllowAnonymous }} + if _, ok := auth.ParseCapabilities(w, r); !ok { + return + } + {{- end }} + handler(w, r) + } +} +`