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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ PLUGINS=\
types \
zaplogger \
zerologger \
arnz
arnz \
tscap

PROTOC_VERSION=27.1
ifeq ($(GOOS),linux)
Expand Down
27 changes: 27 additions & 0 deletions tscap/Makefile
Original file line number Diff line number Diff line change
@@ -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
120 changes: 120 additions & 0 deletions tscap/README.md
Original file line number Diff line number Diff line change
@@ -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)
107 changes: 107 additions & 0 deletions tscap/auth/auth.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
Loading