Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7946221
feat(api): add bucket policies and the principal access reads
pyropy Sep 22, 2026
d86ba17
test(bucketpolicy): expect the policy write to lose the removal race
pyropy Sep 25, 2026
15f801e
refactor(api): drop the bucket policy management routes
pyropy Sep 28, 2026
933e474
test(bucketpolicy): require the policy write and the principal remova…
pyropy Oct 1, 2026
c25b602
fix(api): refuse a policy write naming a principal removed meanwhile
pyropy Oct 1, 2026
3e94b2e
fix(api): report a bucket gone before the policy write as not found
pyropy Oct 1, 2026
19b7e5e
test(bucketpolicy): pass the principal service its delegation store a…
pyropy Oct 1, 2026
90f54ec
fix(api): decode the principalId parameter on the policy read routes
pyropy Oct 1, 2026
ee438de
test(api): drop the policy route path left behind by the management r…
pyropy Oct 1, 2026
64dcc88
test(api): a principal added after a policy write's re-list gets the …
pyropy Oct 1, 2026
aaf980d
fix(api): name the bucketpolicy package in its doc and decode the pol…
pyropy Oct 2, 2026
9df00b1
test(api): a policy write's rotation commits with its document, and a…
pyropy Oct 2, 2026
ba85932
refactor(api): follow the principal store's WithLock and the policy s…
pyropy Oct 5, 2026
1b9ec3e
refactor(api): follow the access key principalId pointer
pyropy Oct 8, 2026
fcd5698
test(api): expect a principalId holding a slash to be refused on the …
pyropy Oct 8, 2026
4e766cb
refactor(api): define the policy wire types in types.go
pyropy Oct 8, 2026
ef2b67b
refactor(store): list a tenant's principal ids without the records
pyropy Oct 8, 2026
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
44 changes: 44 additions & 0 deletions cmd/client/policy/principal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package policy

import (
"github.com/fil-forge/hilt/cmd/client/lib"
"github.com/spf13/cobra"
)

var listCmd = &cobra.Command{
Use: "list <tenant-id> <principal-id>",
Short: "List the policies naming a principal",
Long: "List every policy of the tenant with a statement naming the principal " +
"or the wildcard.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, _, err := lib.InitManagementClient(cmd)
if err != nil {
return err
}
recs, err := c.ListPrincipalPolicies(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
return lib.PrintJSON(cmd, recs)
},
}

var accessCmd = &cobra.Command{
Use: "access <tenant-id> <principal-id>",
Short: "Show a principal's effective actions per bucket",
Long: "Show the actions the principal holds on each bucket, computed from the " +
"tenant's stored policies. Buckets it has no action on are omitted.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, _, err := lib.InitManagementClient(cmd)
if err != nil {
return err
}
buckets, err := c.GetPrincipalAccess(cmd.Context(), args[0], args[1])
if err != nil {
return err
}
return lib.PrintJSON(cmd, buckets)
},
}
17 changes: 17 additions & 0 deletions cmd/client/policy/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Package policy provides the `hilt client policy` command tree: the two
// principal reads computed from bucket policies, via the Tenant REST API, authenticated with the partner key from config
// (auth.partner_key) or the HILT_PARTNER_KEY env var.
package policy

import "github.com/spf13/cobra"

// Cmd is the `hilt client policy` command group.
var Cmd = &cobra.Command{
Use: "policy",
Short: "Read bucket policies by principal via the Tenant REST API",
}

func init() {
Cmd.AddCommand(listCmd)
Cmd.AddCommand(accessCmd)
}
2 changes: 2 additions & 0 deletions cmd/client/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package client
import (
"github.com/fil-forge/hilt/cmd/client/accesskey"
"github.com/fil-forge/hilt/cmd/client/admin"
"github.com/fil-forge/hilt/cmd/client/policy"
"github.com/fil-forge/hilt/cmd/client/principal"
"github.com/fil-forge/hilt/cmd/client/tenant"
"github.com/spf13/cobra"
Expand All @@ -22,4 +23,5 @@ func init() {
Cmd.AddCommand(tenant.Cmd)
Cmd.AddCommand(accesskey.Cmd)
Cmd.AddCommand(principal.Cmd)
Cmd.AddCommand(policy.Cmd)
}
74 changes: 74 additions & 0 deletions pkg/api/policies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package api

import (
"errors"
"net/http"

bucketpolicysvc "github.com/fil-forge/hilt/pkg/api/service/bucketpolicy"
"github.com/fil-forge/hilt/pkg/bucketpolicy"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

// policyHTTPError maps a policy-service error to an echo HTTP error. Known
// errors (see the policy service's errors.go) become their mapped status with
// the error's own message and code; anything else is logged and returned as a
// 500. The code is what tells a bucket with no policy from a bucket that is not
// there, since both are 404.
func policyHTTPError(log *zap.Logger, err error) error {
switch {
case errors.Is(err, bucketpolicysvc.ErrTenantNotFound),
errors.Is(err, bucketpolicysvc.ErrBucketNotFound),
errors.Is(err, bucketpolicysvc.ErrPolicyNotFound),
errors.Is(err, bucketpolicysvc.ErrPrincipalNotFound):
return httpError(http.StatusNotFound, err)
case errors.Is(err, bucketpolicysvc.ErrInvalidPrecondition):
return httpError(http.StatusBadRequest, err)
case errors.Is(err, bucketpolicysvc.ErrPreconditionFailed):
return httpError(http.StatusPreconditionFailed, err)
case errors.Is(err, bucketpolicysvc.ErrConcurrentChange):
return httpError(http.StatusConflict, err)
case errors.Is(err, bucketpolicy.ErrInvalidPolicy):
return httpError(http.StatusUnprocessableEntity, err)
default:
log.Error("request failed", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}
}

// NewListPrincipalPoliciesHandler handles
// GET /tenants/{tenantId}/principals/{principalId}/policies — every policy of the
// tenant with a statement naming the principal or the wildcard.
func NewListPrincipalPoliciesHandler(logger *zap.Logger, policies *bucketpolicysvc.Service) Route {
log := logger.With(zap.String("handler", "ListPrincipalPolicies"))
return NewRoute(http.MethodGet, "/tenants/:tenantId/principals/:principalId/policies", func(c echo.Context) error {
recs, err := policies.ListByPrincipal(c.Request().Context(), tenantParam(c), principalParam(c))
if err != nil {
return policyHTTPError(log, err)
}
items := make([]PrincipalPolicy, len(recs))
for i, r := range recs {
items[i] = PrincipalPolicy{BucketName: r.BucketName, ETag: r.ETag, Policy: r.Policy}
}
return c.JSON(http.StatusOK, PrincipalPolicyList{Items: items})
})
}

// NewGetPrincipalAccessHandler handles
// GET /tenants/{tenantId}/principals/{principalId}/access — the principal's
// effective actions per bucket, computed from the tenant's stored policies.
// Buckets the principal has no action on are omitted.
func NewGetPrincipalAccessHandler(logger *zap.Logger, policies *bucketpolicysvc.Service) Route {
log := logger.With(zap.String("handler", "GetPrincipalAccess"))
return NewRoute(http.MethodGet, "/tenants/:tenantId/principals/:principalId/access", func(c echo.Context) error {
access, err := policies.Access(c.Request().Context(), tenantParam(c), principalParam(c))
if err != nil {
return policyHTTPError(log, err)
}
buckets := make([]BucketAccess, len(access))
for i, a := range access {
buckets[i] = BucketAccess{Name: a.Name, Actions: a.Actions}
}
return c.JSON(http.StatusOK, PrincipalAccess{Buckets: buckets})
})
}
135 changes: 135 additions & 0 deletions pkg/api/policies_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
package api_test

import (
"encoding/json"
"net/http"
"testing"

"github.com/fil-forge/hilt/internal/testutil"
"github.com/fil-forge/hilt/pkg/api"
bucketpolicysvc "github.com/fil-forge/hilt/pkg/api/service/bucketpolicy"
"github.com/fil-forge/hilt/pkg/bucketpolicy"
"github.com/fil-forge/hilt/pkg/grant"
accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory"
bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory"
bucketpolicymemory "github.com/fil-forge/hilt/pkg/store/bucketpolicy/memory"
delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory"
principalmemory "github.com/fil-forge/hilt/pkg/store/principal/memory"
"github.com/fil-forge/hilt/pkg/store/tenant"
tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory"
vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory"
"github.com/fil-forge/ucantone/did"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)

type policyDeps struct {
buckets *bucketmemory.Store
principals *principalmemory.Store
policies *bucketpolicysvc.Service
tenantID did.DID // "tenant-1"
}

// setupPolicies serves every policy route over memory stores, with two tenants
// and one bucket, "photos", owned by "tenant-1".
func setupPolicies(t *testing.T) (*echo.Echo, *policyDeps) {
t.Helper()
tenants := tenantmemory.New()
deps := &policyDeps{
buckets: bucketmemory.New(),
principals: principalmemory.New(),
tenantID: testutil.RandomDID(t),
}
require.NoError(t, tenants.Add(t.Context(), deps.tenantID, "tenant-1", testutil.RandomDID(t), tenant.Active))
require.NoError(t, tenants.Add(t.Context(), testutil.RandomDID(t), "tenant-2", testutil.RandomDID(t), tenant.Active))
require.NoError(t, deps.buckets.Add(t.Context(), testutil.RandomDID(t), deps.tenantID, "photos"))
require.NoError(t, deps.principals.Add(t.Context(), deps.tenantID, "user-1"))

// No principal holds a key here, so the grant rotator has nothing to
// issue; the routes are what is under test.
grants := grant.NewRotator(zap.NewNop(), delegationmemory.New(), accesskeymemory.New(), vaultmemory.New(), &testutil.FakeSwarf{})
svc := bucketpolicysvc.New(zap.NewNop(), tenants, deps.buckets, deps.principals, bucketpolicymemory.New(), grants)
deps.policies = svc
e := echo.New()
for _, r := range []api.Route{
api.NewListPrincipalPoliciesHandler(zap.NewNop(), svc),
api.NewGetPrincipalAccessHandler(zap.NewNop(), svc),
} {
e.Add(r.Method, r.Path, r.Handler)
}
return e, deps
}

func allowUser1(actions ...string) bucketpolicy.Statement {
return bucketpolicy.Statement{Effect: bucketpolicy.Allow, Principal: bucketpolicy.Only("user-1"), Actions: actions}
}

// createPolicy writes the photos bucket's first policy through the service,
// as the S3 PutBucketPolicy path does, and returns its ETag.
func createPolicy(t *testing.T, deps *policyDeps, statements ...bucketpolicy.Statement) string {
t.Helper()
etag, _, err := deps.policies.Put(t.Context(), "tenant-1", "photos", bucketpolicy.Policy{Statements: statements}, nil)
require.NoError(t, err)
return etag
}

func TestPrincipalPolicyReadHandlers(t *testing.T) {
t.Run("lists the policies naming the principal", func(t *testing.T) {
e, deps := setupPolicies(t)
etag := createPolicy(t, deps, allowUser1("s3:GetObject"))

rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/principals/user-1/policies", nil)
require.Equal(t, http.StatusOK, rec.Code)
var list api.PrincipalPolicyList
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list))
require.Len(t, list.Items, 1)
require.Equal(t, "photos", list.Items[0].BucketName)
require.Equal(t, etag, list.Items[0].ETag)
})

t.Run("reports the principal's effective actions per bucket", func(t *testing.T) {
e, deps := setupPolicies(t)
createPolicy(t, deps, allowUser1("s3:GetObject", "s3:ListBucket"))

rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/principals/user-1/access", nil)
require.Equal(t, http.StatusOK, rec.Code)
var access api.PrincipalAccess
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &access))
require.Equal(t, []api.BucketAccess{
{Name: "photos", Actions: []string{"s3:GetObject", "s3:ListBucket"}},
}, access.Buckets)
})

t.Run("a bucket the principal cannot reach is omitted", func(t *testing.T) {
e, deps := setupPolicies(t)
require.NoError(t, deps.principals.Add(t.Context(), deps.tenantID, "user-2"))
createPolicy(t, deps, allowUser1("s3:GetObject"))

rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/principals/user-2/access", nil)
require.Equal(t, http.StatusOK, rec.Code)
var access api.PrincipalAccess
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &access))
require.Empty(t, access.Buckets)
})

t.Run("an unknown principal is 404", func(t *testing.T) {
e, _ := setupPolicies(t)
for _, path := range []string{
"/tenants/tenant-1/principals/ghost/policies",
"/tenants/tenant-1/principals/ghost/access",
} {
rec := doRequest(t, e, http.MethodGet, path, nil)
require.Equal(t, http.StatusNotFound, rec.Code)
}
})

t.Run("a principalId holding a slash is refused", func(t *testing.T) {
e, _ := setupPolicies(t)
for _, route := range []string{"access", "policies"} {
rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/principals/a%2Fb/"+route, nil)
require.Equal(t, http.StatusUnprocessableEntity, rec.Code, route)
require.Equal(t, "InvalidPrincipalID", decodeError(t, rec).Code, route)
}
})
}
46 changes: 46 additions & 0 deletions pkg/api/service/bucketpolicy/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package bucketpolicy

import "github.com/fil-forge/ucantone/errors"

// Error names for the policy service's known errors, exported so callers can
// match on the stable Name() of a serialized failure.
const (
TenantNotFoundErrorName = "TenantNotFound"
BucketNotFoundErrorName = "BucketNotFound"
PolicyNotFoundErrorName = "PolicyNotFound"
PrincipalNotFoundErrorName = "PrincipalNotFound"
InvalidPreconditionName = "InvalidPrecondition"
PreconditionFailedErrorName = "PreconditionFailed"
ConcurrentChangeErrorName = "ConcurrentChange"
)

// Known errors returned by the policy [Service]. Handlers map these to HTTP
// status codes with errors.Is; a policy the caller may not store is reported
// as an error wrapping [bucketpolicy.ErrInvalidPolicy], and anything else is an
// unexpected (500-class) failure.
var (
// ErrTenantNotFound is returned when no tenant exists for the external id.
ErrTenantNotFound = errors.New(TenantNotFoundErrorName, "tenant not found")
// ErrBucketNotFound is returned when the tenant has no bucket with that
// name. A bucket owned by another tenant is reported the same way.
ErrBucketNotFound = errors.New(BucketNotFoundErrorName, "bucket not found")
// ErrPolicyNotFound is returned when the bucket exists and has no policy.
// It is distinct from [ErrBucketNotFound] so a caller can tell a bucket it
// may not see from one no principal reaches yet.
ErrPolicyNotFound = errors.New(PolicyNotFoundErrorName, "bucket has no policy")
// ErrPrincipalNotFound is returned when the tenant has no principal with
// that principalId.
ErrPrincipalNotFound = errors.New(PrincipalNotFoundErrorName, "principal not found")
// ErrInvalidPrecondition is returned when the request carries neither
// If-Match nor If-None-Match: *, or carries both, or carries an
// If-None-Match other than *. Every write to a policy states the version it
// expects.
ErrInvalidPrecondition = errors.New(InvalidPreconditionName,
"a policy write must carry either If-Match with the current ETag or If-None-Match: *")
// ErrPreconditionFailed is returned when the bucket's policy is not in the
// state the request conditioned on. Nothing is written.
ErrPreconditionFailed = errors.New(PreconditionFailedErrorName, "the bucket's policy has changed")
// ErrConcurrentChange is returned when another write held a lock this call
// gave up waiting for. Nothing was written and the call can be repeated.
ErrConcurrentChange = errors.New(ConcurrentChangeErrorName, "the policy is being changed concurrently, retry the request")
)
Loading
Loading