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
7 changes: 7 additions & 0 deletions api-contracts/openapi/components/schemas/api_tokens.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ APIToken:
type: string
description: The name of the API token.
maxLength: 255
readOnly:
type: boolean
description: Whether the token is limited to viewer access over REST and cannot use gRPC.
expiresAt:
type: string
format: date-time
Expand All @@ -15,6 +18,7 @@ APIToken:
- metadata
- name
- expiresAt
- readOnly

CreateAPITokenRequest:
type: object
Expand All @@ -23,6 +27,9 @@ CreateAPITokenRequest:
type: string
description: A name for the API token.
maxLength: 255
readOnly:
type: boolean
description: Limit the token to viewer access over REST and deny all gRPC calls. Defaults to false.
expiresIn:
type: string
description: The duration for which the token is valid.
Expand Down
59 changes: 59 additions & 0 deletions api/v1/server/authz/bearer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package authz

import (
"context"
"net/http/httptest"
"testing"

"github.com/google/uuid"
"github.com/hatchet-dev/hatchet/api/v1/server/middleware"
"github.com/hatchet-dev/hatchet/pkg/analytics"
"github.com/hatchet-dev/hatchet/pkg/config/database"
"github.com/hatchet-dev/hatchet/pkg/config/server"
"github.com/hatchet-dev/hatchet/pkg/repository"
"github.com/hatchet-dev/hatchet/pkg/repository/sqlcv1"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
)

type bearerTokenRepo struct {
repository.APITokenRepository
readOnly bool
}

func (r bearerTokenRepo) GetAPITokenById(_ context.Context, id uuid.UUID) (*sqlcv1.APIToken, error) {
return &sqlcv1.APIToken{ID: id, ReadOnly: r.readOnly}, nil
}

type bearerRepo struct {
repository.Repository
tokens bearerTokenRepo
}

func (r bearerRepo) APIToken() repository.APITokenRepository { return r.tokens }

func TestBearerTokenPermissions(t *testing.T) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests lack required comments

The new exported test functions have no godoc comments. The repository requires comments on exported Go functions, so this must be fixed before merging. Add short comments explaining what each test protects. The same pattern appears in TestReadOnlyTokenAuthentication, TestGenerateTenantTokenReadOnly, and the new persistence test.

Context Used: Comment and logging conventions for the Hatchet codebase (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

for _, readOnly := range []bool{false, true} {
config := &server.ServerConfig{Layer: &database.Layer{V1: bearerRepo{tokens: bearerTokenRepo{readOnly: readOnly}}}}
config.Auth.AllowedWriteOperations = []string{"WorkflowRunCreate"}
auth, err := NewAuthZ(config)
require.NoError(t, err)
for _, op := range append([]string{"WorkflowRunList", "V1CelDebug", "WorkflowRunCreate", "V1TaskCancel", "EventCreate"}, restrictedWithBearerToken...) {
t.Run(op+map[bool]string{true: "/read-only", false: "/unrestricted"}[readOnly], func(t *testing.T) {
c := echo.New().NewContext(httptest.NewRequest("GET", "/", nil), httptest.NewRecorder())
c.Set(string(analytics.APITokenIDKey), uuid.New())
err := auth.handleBearerAuth(c, &middleware.RouteInfo{OperationID: op})
banned := false
for _, restricted := range restrictedWithBearerToken {
banned = banned || op == restricted
}
if banned || (readOnly && op != "WorkflowRunList" && op != "V1CelDebug") {
require.Error(t, err)
} else {
require.NoError(t, err)
}
require.True(t, CanViewPayloads(c))
})
}
}
}
16 changes: 14 additions & 2 deletions api/v1/server/authz/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"fmt"
"net/http"

"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog"

"github.com/hatchet-dev/hatchet/api/v1/server/middleware"
"github.com/hatchet-dev/hatchet/pkg/analytics"
"github.com/hatchet-dev/hatchet/pkg/auth/rbac"
"github.com/hatchet-dev/hatchet/pkg/config/server"
"github.com/hatchet-dev/hatchet/pkg/repository/fairpool"
Expand Down Expand Up @@ -97,8 +99,7 @@ var restrictedWithBearerToken = []string{
"ApiTokenUpdateRevoke",
}

// At the moment, there's no further bearer auth because bearer tokens are admin-scoped
// and we check that the bearer token has access to the tenant in the authn step.
// Bearer tokens have tenant access checked by authentication and optional viewer restrictions here.
func (a *AuthZ) handleBearerAuth(c echo.Context, r *middleware.RouteInfo) error {
// check for is_exchange_token set in the context, in which case we need to validate the user set in the context
// exchange tokens are subject to the same RBAC restrictions as cookie auth, since they represent a user. only
Expand All @@ -122,6 +123,17 @@ func (a *AuthZ) handleBearerAuth(c echo.Context, r *middleware.RouteInfo) error
if rbac.OperationIn(r.OperationID, restrictedWithBearerToken) {
return echo.NewHTTPError(http.StatusUnauthorized, "Not authorized to perform this operation")
}
tokenID, ok := c.Get(string(analytics.APITokenIDKey)).(uuid.UUID)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing API token")
}
token, err := a.config.V1.APIToken().GetAPITokenById(c.Request().Context(), tokenID)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "Invalid API token")
}
if token.ReadOnly {
return a.authorizeTenantOperations(string(sqlcv1.TenantMemberRoleVIEWER), r)
}
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion api/v1/server/handlers/api-tokens/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func (a *APITokenService) ApiTokenCreate(ctx echo.Context, request gen.ApiTokenC
expiresAt = &e
}

token, err := a.config.Auth.JWTManager.GenerateTenantToken(ctx.Request().Context(), tenantId, request.Body.Name, false, expiresAt)
token, err := a.config.Auth.JWTManager.GenerateTenantToken(ctx.Request().Context(), tenantId, request.Body.Name, false, request.Body.ReadOnly != nil && *request.Body.ReadOnly, expiresAt)

if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion api/v1/server/handlers/v1/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (p *Proxy[in, out]) Do(ctx context.Context, tenant *sqlcv1.Tenant, input *i
expiresAt := time.Now().Add(5 * time.Minute).UTC()

// generate the API token for the proxy request
tok, err := p.config.Auth.JWTManager.GenerateTenantToken(ctx, tenantId, "proxy", true, &expiresAt)
tok, err := p.config.Auth.JWTManager.GenerateTenantToken(ctx, tenantId, "proxy", true, false, &expiresAt)

if err != nil {
return nil, err
Expand Down
Loading
Loading