Skip to content

Commit 514e283

Browse files
authored
feat: add tscap Tailscale Application Capabilities plugin (#240)
1 parent 40bcb98 commit 514e283

38 files changed

Lines changed: 3031 additions & 1 deletion

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ PLUGINS=\
2424
types \
2525
zaplogger \
2626
zerologger \
27-
arnz
27+
arnz \
28+
tscap
2829

2930
PROTOC_VERSION=27.1
3031
ifeq ($(GOOS),linux)

tscap/Makefile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#! /usr/bin/make
2+
#
3+
# Makefile for goa v3 tscap plugin
4+
#
5+
# Targets:
6+
# - "gen" generates the goa files for the example services
7+
# - "example" generates the example files for the example services
8+
9+
# include common Makefile content for plugins
10+
PLUGIN_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
11+
include ../plugins.mk
12+
13+
gen:
14+
@goa gen goa.design/plugins/v3/tscap/example/design -o "$(PLUGIN_DIR)/example" && \
15+
make example
16+
17+
example:
18+
@rm -rf "$(PLUGIN_DIR)/example/cmd"
19+
goa example goa.design/plugins/v3/tscap/example/design -o "$(PLUGIN_DIR)/example"
20+
21+
build-examples:
22+
@cd "$(PLUGIN_DIR)/example" && \
23+
go build ./cmd/example
24+
25+
clean:
26+
@cd "$(PLUGIN_DIR)/example" && \
27+
rm -f example

tscap/README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# Tailscale App Capabilities Plugin
2+
3+
The `tscap` plugin is a [Goa](https://github.com/goadesign/goa/tree/v3) plugin
4+
that provides declarative authorization using Tailscale app capabilities.
5+
6+
Inspired by the implementation of [arnz](../arnz/README.md).
7+
8+
## Requirements
9+
10+
- Tailscale 1.92+ (app capabilities feature)
11+
- Service must be served via `tailscale serve --accept-app-caps`
12+
13+
## Enabling the Plugin
14+
15+
To enable the plugin and make use of the tscap DSL simply import both the `tscap`
16+
and the `dsl` packages as follows:
17+
18+
```go
19+
import (
20+
. "goa.design/goa/v3/dsl"
21+
tscap "goa.design/plugins/v3/tscap/dsl"
22+
)
23+
```
24+
25+
### Tailscale Setup
26+
27+
```bash
28+
tailscale serve --accept-app-caps example.com/cap/myapp https+insecure://localhost:8080
29+
```
30+
31+
### ACL Grants
32+
33+
Configure grants in your tailnet policy:
34+
35+
```json
36+
{
37+
"grants": [
38+
{
39+
"src": ["group:developers"],
40+
"dst": ["tag:myapp"],
41+
"app": {
42+
"example.com/cap/myapp": [{"action": ["*"], "resources": ["*"]}]
43+
}
44+
},
45+
{
46+
"src": ["group:finance"],
47+
"dst": ["tag:myapp"],
48+
"app": {
49+
"example.com/cap/myapp": [{"action": ["read"], "resources": ["items/*"]}]
50+
}
51+
}
52+
]
53+
}
54+
```
55+
56+
## Effects on Code Generation
57+
58+
Enabling the plugin changes the behavior of the `gen` command of the `goa` tool.
59+
60+
The `gen` command output is modified as follows:
61+
62+
1. Generates middleware that extracts the `Tailscale-App-Capabilities` header
63+
2. Parses the JSON capabilities from the header
64+
3. Checks if the caller's grants satisfy the method's requirements
65+
4. Returns 401 if header is missing, 403 if permissions are insufficient
66+
67+
## Design
68+
69+
This plugin adds the following functions to the Goa DSL:
70+
71+
* `Require` declares that the method requires a Tailscale app capability with the
72+
specified action and resource.
73+
* `AllowAnonymous` marks the method as not requiring any capability check. Requests
74+
without the capabilities header will be allowed through.
75+
76+
The usage and effect of the DSL functions are described in the [Godocs](https://godoc.org/goa.design/plugins/v3/tscap/dsl)
77+
78+
Here is an example defining capability requirements at a method level.
79+
80+
```go
81+
var _ = Service("myservice", func() {
82+
Method("list", func() {
83+
// Requires the caller to have "read" action on "*" resource
84+
tscap.Require("example.com/cap/myapp", "read", "*")
85+
HTTP(func() { GET("/items") })
86+
})
87+
88+
Method("create", func() {
89+
// Requires the caller to have "write" action on "items/*" resource
90+
tscap.Require("example.com/cap/myapp", "write", "items/*")
91+
HTTP(func() { POST("/items") })
92+
})
93+
94+
Method("health", func() {
95+
// No capability check required
96+
tscap.AllowAnonymous()
97+
HTTP(func() { GET("/health") })
98+
})
99+
})
100+
```
101+
102+
## Matching Semantics
103+
104+
Grants in Tailscale ACLs can use wildcards (`*`). The DSL specifies exact requirements:
105+
106+
| Grant Action | Required Action | Match? |
107+
|--------------|-----------------|--------|
108+
| `["*"]` | `"read"` | Yes |
109+
| `["read"]` | `"read"` | Yes |
110+
| `["write"]` | `"read"` | No |
111+
112+
| Grant Resource | Required Resource | Match? |
113+
|----------------|-------------------|--------|
114+
| `["*"]` | `"items/123"` | Yes |
115+
| `["items/*"]` | `"items/*"` | Yes (exact) |
116+
| `["items/123"]` | `"items/456"` | No |
117+
118+
## References
119+
120+
- [Application capabilities](https://tailscale.com/docs/features/access-control/grants/grants-app-capabilities)

tscap/auth/auth.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package auth
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
)
7+
8+
const Header = "Tailscale-App-Capabilities"
9+
10+
// Capabilities represents parsed app capabilities from the Tailscale header.
11+
// The map key is the capability name (e.g., "example.com/cap/myapp").
12+
type Capabilities map[string][]Grant
13+
14+
// Grant represents a single permission grant from a Tailscale ACL.
15+
type Grant struct {
16+
Action []string `json:"action"`
17+
Resources []string `json:"resources"`
18+
}
19+
20+
// Requirement specifies what capability is needed for a method.
21+
type Requirement struct {
22+
Capability string
23+
Action string
24+
Resource string
25+
}
26+
27+
// Gate stores the authorization requirements for a method.
28+
type Gate struct {
29+
MethodName string
30+
AllowAnonymous bool
31+
Requirement *Requirement
32+
}
33+
34+
// ParseCapabilities extracts capabilities from the Tailscale header.
35+
func ParseCapabilities(w http.ResponseWriter, r *http.Request) (Capabilities, bool) {
36+
header := r.Header.Get(Header)
37+
if header == "" {
38+
WriteUnauthenticated(w, "missing capabilities header")
39+
return nil, false
40+
}
41+
42+
var caps Capabilities
43+
if err := json.Unmarshal([]byte(header), &caps); err != nil {
44+
WriteUnauthenticated(w, "invalid capabilities header")
45+
return nil, false
46+
}
47+
48+
return caps, true
49+
}
50+
51+
// Check verifies the caller has the required capability.
52+
func Check(w http.ResponseWriter, caps Capabilities, req Requirement) bool {
53+
grants, ok := caps[req.Capability]
54+
if !ok {
55+
WriteUnauthorized(w, "missing required capability")
56+
return false
57+
}
58+
59+
for _, g := range grants {
60+
if matchesAction(g.Action, req.Action) && matchesResource(g.Resources, req.Resource) {
61+
return true
62+
}
63+
}
64+
65+
WriteUnauthorized(w, "insufficient permissions")
66+
return false
67+
}
68+
69+
// matchesAction checks if the granted actions satisfy the required action.
70+
func matchesAction(granted []string, required string) bool {
71+
for _, a := range granted {
72+
if a == "*" || a == required {
73+
return true
74+
}
75+
}
76+
return false
77+
}
78+
79+
// matchesResource checks if the granted resources satisfy the required resource.
80+
func matchesResource(granted []string, required string) bool {
81+
for _, r := range granted {
82+
if r == "*" || r == required {
83+
return true
84+
}
85+
}
86+
return false
87+
}
88+
89+
// WriteUnauthenticated writes a 401 response.
90+
func WriteUnauthenticated(w http.ResponseWriter, message string) {
91+
w.Header().Set("Content-Type", "application/json")
92+
w.WriteHeader(http.StatusUnauthorized)
93+
json.NewEncoder(w).Encode(map[string]string{
94+
"error": "unauthenticated",
95+
"message": message,
96+
})
97+
}
98+
99+
// WriteUnauthorized writes a 403 response.
100+
func WriteUnauthorized(w http.ResponseWriter, message string) {
101+
w.Header().Set("Content-Type", "application/json")
102+
w.WriteHeader(http.StatusForbidden)
103+
json.NewEncoder(w).Encode(map[string]string{
104+
"error": "unauthorized",
105+
"message": message,
106+
})
107+
}

0 commit comments

Comments
 (0)