From b1e712722740bc2e22d93a21b54bc925d8339f6b Mon Sep 17 00:00:00 2001 From: Lucas Fleischer Date: Mon, 10 Aug 2026 09:39:47 -0300 Subject: [PATCH 1/5] feat: add permission generation tool and integrate into build process --- .github/workflows/pull-request-main.yaml | 3 + .vacuum.yaml | 23 +++ Taskfile.yaml | 6 + api/openapi.yaml | 80 +++++++ client/client.gen.go | 188 +++++++++++++++++ cmd/gen-permissions/main.go | 253 +++++++++++++++++++++++ generate.go | 1 + ginserver/ginserver.gen.go | 213 +++++++++++++++++++ go.mod | 5 +- permissions/permissions.gen.go | 53 +++++ permissions/permissions_test.go | 83 ++++++++ stdserver/stdserver.gen.go | 4 + 12 files changed, 911 insertions(+), 1 deletion(-) create mode 100644 cmd/gen-permissions/main.go create mode 100644 permissions/permissions.gen.go create mode 100644 permissions/permissions_test.go diff --git a/.github/workflows/pull-request-main.yaml b/.github/workflows/pull-request-main.yaml index 2bdecde..9ca0494 100644 --- a/.github/workflows/pull-request-main.yaml +++ b/.github/workflows/pull-request-main.yaml @@ -37,6 +37,9 @@ jobs: - name: Lint OpenAPI spec run: task validate-openapi + - name: Lint permissions + run: task lint:permissions + generate: name: Verify Code Generation is Up-to-Date runs-on: ubuntu-latest diff --git a/.vacuum.yaml b/.vacuum.yaml index 4d39d20..6649b82 100644 --- a/.vacuum.yaml +++ b/.vacuum.yaml @@ -18,3 +18,26 @@ rules: # Error response descriptions (e.g. "Not found", "Internal server error") repeat # across endpoints by design — they describe the same error type consistently. description-duplication: false + + # Every operation must declare the crec:* permission(s) it requires, or + # explicitly declare x-permissions: [] for an intentional exemption + # (see cmd/gen-permissions exemptRoutes). The authoritative, exemption-aware + # gate is `task lint:permissions` (cmd/gen-permissions) — this rule just + # surfaces the same problem earlier, in `vacuum lint` / the vacuum language + # server. + x-permissions-required: + description: Every operation must declare the crec:* permission(s) it requires, or explicitly declare x-permissions:[] for an intentional exemption (see cmd/gen-permissions exemptRoutes) + message: "operation is missing x-permissions: [crec::, ...] (or x-permissions: [] for an intentional exemption)" + severity: error + given: $.paths[*][*] + then: + field: x-permissions + function: schema + functionOptions: + forceValidation: true + schema: + type: array + minItems: 0 + items: + type: string + pattern: "^crec:[^:]+:[^:]+$" diff --git a/Taskfile.yaml b/Taskfile.yaml index 31639b7..a2451fb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -30,3 +30,9 @@ tasks: desc: "Validate openapi spec" cmds: - vacuum lint --ruleset .vacuum.yaml ./api/openapi.yaml + + lint:permissions: + silent: true + desc: "Verify every endpoint declares x-permissions" + cmds: + - go run ./cmd/gen-permissions ./api/openapi.yaml "$(mktemp)" permissions diff --git a/api/openapi.yaml b/api/openapi.yaml index da1fca5..0177acf 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -34,6 +34,7 @@ paths: tags: - Health operationId: getHealthCheck + x-permissions: [] # served before an auth context exists; see cmd/gen-permissions exemptRoutes summary: Health check endpoint responses: "200": @@ -48,10 +49,13 @@ paths: tags: - Networks operationId: listNetworks + x-permissions: [crec:network:read] summary: Retrieves available networks. responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Successful operation content: @@ -71,6 +75,7 @@ paths: tags: - Wallets operationId: createWallet + x-permissions: [crec:wallet:create] summary: Creates a new wallet. requestBody: description: Wallet creation parameters. @@ -83,6 +88,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "201": description: Wallet created successfully content: @@ -108,6 +115,7 @@ paths: tags: - Wallets operationId: listWallets + x-permissions: [crec:wallet:read] summary: Retrieves wallets for the organization. description: | Returns wallets for the organization. Archived wallets are excluded by default. @@ -171,6 +179,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Successful operation content: @@ -190,6 +200,7 @@ paths: tags: - Wallets operationId: getWallet + x-permissions: [crec:wallet:read] summary: Retrieves a specific wallet by ID. parameters: - in: path @@ -202,6 +213,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Wallet found content: @@ -227,6 +240,7 @@ paths: tags: - Wallets operationId: updateWallet + x-permissions: [crec:wallet:update, crec:wallet:archive] summary: Updates a wallet name. parameters: - in: path @@ -247,6 +261,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Wallet updated successfully content: @@ -281,6 +297,7 @@ paths: tags: - Channels operationId: createChannel + x-permissions: [crec:channel:create] summary: Creates a new channel. requestBody: description: Channel creation parameters. @@ -293,6 +310,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "201": description: Channel created successfully content: @@ -318,6 +337,7 @@ paths: tags: - Channels operationId: listChannels + x-permissions: [crec:channel:read] summary: Retrieves channels for the organization. description: | Returns channels for the organization. Archived channels are excluded by default. @@ -356,6 +376,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Successful operation content: @@ -375,6 +397,7 @@ paths: tags: - Channels operationId: getChannel + x-permissions: [crec:channel:read] summary: Retrieves a specific channel by ID. parameters: - in: path @@ -387,6 +410,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Channel found content: @@ -412,6 +437,7 @@ paths: tags: - Channels operationId: updateChannel + x-permissions: [crec:channel:update, crec:channel:archive] summary: Updates a channel. description: | Updates channel properties. Can be used to update the name, description, or to transition the channel status (e.g., to archived). @@ -435,6 +461,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Channel updated successfully content: @@ -469,6 +497,7 @@ paths: tags: - Queries operationId: createQuery + x-permissions: [crec:query:create] summary: Creates a chain query under a channel. description: | Creates a channel-scoped Chain Query Request. New queries are rejected for archived channels. @@ -524,6 +553,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "202": description: Query accepted for execution. content: @@ -570,6 +601,7 @@ paths: tags: - Queries operationId: listQueries + x-permissions: [crec:query:read] summary: Lists chain queries for a channel. description: | Returns chain query records for the channel. @@ -631,6 +663,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Successful operation content: @@ -748,6 +782,7 @@ paths: tags: - Queries operationId: getQuery + x-permissions: [crec:query:read] summary: Retrieves a specific chain query by ID. description: | Returns request fields, status timestamps, expiration, and terminal verifiable fields from the query resource. @@ -768,6 +803,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Query found content: @@ -815,6 +852,7 @@ paths: tags: - Watchers operationId: createWatcher + x-permissions: [crec:watcher:create] summary: Creates a watcher in a channel. parameters: - in: path @@ -835,6 +873,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "201": description: Watcher created successfully content: @@ -867,6 +907,7 @@ paths: tags: - Watchers operationId: listWatchers + x-permissions: [crec:watcher:read] summary: Retrieves watchers for a channel. description: | Returns watchers for the specified channel. Archived watchers are excluded by default. @@ -942,6 +983,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Successful operation content: @@ -968,6 +1011,7 @@ paths: tags: - Watchers operationId: getWatcher + x-permissions: [crec:watcher:read] summary: Retrieves a specific watcher by ID. parameters: - in: path @@ -987,6 +1031,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Watcher found content: @@ -1012,6 +1058,7 @@ paths: tags: - Watchers operationId: updateWatcher + x-permissions: [crec:watcher:update, crec:watcher:archive] summary: Updates a watcher. description: | Updates watcher properties. Can be used to update the name or to transition the watcher status (e.g., to archived). @@ -1042,6 +1089,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Watcher updated successfully content: @@ -1083,6 +1132,7 @@ paths: tags: - Operations operationId: createOperation + x-permissions: [crec:operation:create] summary: Creates a signed operation or draft request, scoped to a channel. description: | Creates a signed or draft operation first, depending on the presence of a signature. @@ -1108,6 +1158,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "201": description: Operation created successfully content: @@ -1144,6 +1196,7 @@ paths: tags: - Operations operationId: listOperations + x-permissions: [crec:operation:read] summary: Retrieves operations for a channel. description: | Returns operation records for the channel, including audit subjects and preview snapshots when available. @@ -1234,6 +1287,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Successful operation content: @@ -1274,6 +1329,7 @@ paths: tags: - Operations operationId: getOperation + x-permissions: [crec:operation:read] summary: Gets Operation record for a specific operation. description: | Returns the operation record. @@ -1295,6 +1351,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Operation found content: @@ -1320,6 +1378,7 @@ paths: tags: - Operations operationId: finalizeOrCancelOperation + x-permissions: [crec:operation:sign] summary: Finalizes or cancels an operation. description: | Finalize by sending status accepted with signature and digest. @@ -1351,6 +1410,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Operation updated successfully content: @@ -1385,6 +1446,7 @@ paths: tags: - Events operationId: listChannelEvents + x-permissions: [crec:event:read] summary: Retrieves events from a channel. parameters: - in: path @@ -1412,6 +1474,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Events retrieved successfully content: @@ -1446,6 +1510,7 @@ paths: tags: - Events operationId: searchChannelEvents + x-permissions: [crec:event:read] summary: Query and search historical events from a channel. description: | Retrieves events with filtering capabilities. Use this endpoint for historical queries and searches. For real-time polling, use the /events endpoint. @@ -1595,6 +1660,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Events retrieved successfully content: @@ -1629,6 +1696,7 @@ paths: tags: - Events operationId: getChannelEvent + x-permissions: [crec:event:read] summary: Retrieves a specific event by ID from a channel. parameters: - in: path @@ -1648,6 +1716,8 @@ paths: responses: "401": $ref: "#/components/responses/OrganizationNotFound" + "403": + $ref: "#/components/responses/PermissionDenied" "200": description: Event found content: @@ -1691,6 +1761,15 @@ components: schema: $ref: "#/components/schemas/ApplicationError" example: {type: "ORGANIZATION_NOT_FOUND", message: "organization not found"} + PermissionDenied: + description: > + Forbidden — the authenticated principal lacks the required CREC permission + for this operation. + content: + application/json: + schema: + $ref: "#/components/schemas/ApplicationError" + example: {type: "PERMISSION_DENIED", message: "principal lacks permission crec:wallet:create"} schemas: WalletStatus: type: string @@ -2229,6 +2308,7 @@ components: - "CONFLICT" - "INTERNAL_ERROR" - "ORGANIZATION_NOT_FOUND" + - "PERMISSION_DENIED" code: $ref: "#/components/schemas/ApplicationErrorCode" message: diff --git a/client/client.gen.go b/client/client.gen.go index 6d215b1..5b86af6 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -28,6 +28,7 @@ const ( INTERNALERROR ApplicationErrorType = "INTERNAL_ERROR" NOTFOUND ApplicationErrorType = "NOT_FOUND" ORGANIZATIONNOTFOUND ApplicationErrorType = "ORGANIZATION_NOT_FOUND" + PERMISSIONDENIED ApplicationErrorType = "PERMISSION_DENIED" VALIDATIONERROR ApplicationErrorType = "VALIDATION_ERROR" ) @@ -1141,6 +1142,9 @@ type WatcherSummary struct { // OrganizationNotFound Standard error response body. type OrganizationNotFound = ApplicationError +// PermissionDenied Standard error response body. +type PermissionDenied = ApplicationError + // ListChannelsParams defines parameters for ListChannels. type ListChannelsParams struct { // Name Filter channels by name (case-insensitive partial match) @@ -4562,6 +4566,7 @@ type ListChannelsResponse struct { HTTPResponse *http.Response JSON200 *ChannelList JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON500 *ApplicationError } @@ -4587,6 +4592,7 @@ type CreateChannelResponse struct { JSON201 *Channel JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON500 *ApplicationError } @@ -4611,6 +4617,7 @@ type GetChannelResponse struct { HTTPResponse *http.Response JSON200 *Channel JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4637,6 +4644,7 @@ type UpdateChannelResponse struct { JSON200 *Channel JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4663,6 +4671,7 @@ type ListChannelEventsResponse struct { JSON200 *EventList JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4689,6 +4698,7 @@ type SearchChannelEventsResponse struct { JSON200 *EventList JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4714,6 +4724,7 @@ type GetChannelEventResponse struct { HTTPResponse *http.Response JSON200 *Event JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4739,6 +4750,7 @@ type ListOperationsResponse struct { HTTPResponse *http.Response JSON200 *OperationList JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4765,6 +4777,7 @@ type CreateOperationResponse struct { JSON201 *OperationResponse JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4790,6 +4803,7 @@ type GetOperationResponse struct { HTTPResponse *http.Response JSON200 *Operation JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4816,6 +4830,7 @@ type FinalizeOrCancelOperationResponse struct { JSON200 *Operation JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON409 *ApplicationError } @@ -4841,6 +4856,7 @@ type ListQueriesResponse struct { HTTPResponse *http.Response JSON200 *QueryList JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4867,6 +4883,7 @@ type CreateQueryResponse struct { JSON202 *QueryAcceptedResponse JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON409 *ApplicationError JSON429 *ApplicationError @@ -4894,6 +4911,7 @@ type GetQueryResponse struct { HTTPResponse *http.Response JSON200 *Query JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4919,6 +4937,7 @@ type ListWatchersResponse struct { HTTPResponse *http.Response JSON200 *WatcherList JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4945,6 +4964,7 @@ type CreateWatcherResponse struct { JSON201 *Watcher JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4970,6 +4990,7 @@ type GetWatcherResponse struct { HTTPResponse *http.Response JSON200 *Watcher JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -4997,6 +5018,7 @@ type UpdateWatcherResponse struct { JSON202 *Watcher JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -5044,6 +5066,7 @@ type ListNetworksResponse struct { HTTPResponse *http.Response JSON200 *NetworkList JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON500 *ApplicationError } @@ -5068,6 +5091,7 @@ type ListWalletsResponse struct { HTTPResponse *http.Response JSON200 *WalletList JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON500 *ApplicationError } @@ -5093,6 +5117,7 @@ type CreateWalletResponse struct { JSON201 *Wallet JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON500 *ApplicationError } @@ -5117,6 +5142,7 @@ type GetWalletResponse struct { HTTPResponse *http.Response JSON200 *Wallet JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -5143,6 +5169,7 @@ type UpdateWalletResponse struct { JSON200 *Wallet JSON400 *ApplicationError JSON401 *OrganizationNotFound + JSON403 *PermissionDenied JSON404 *ApplicationError JSON500 *ApplicationError } @@ -5479,6 +5506,13 @@ func ParseListChannelsResponse(rsp *http.Response) (*ListChannelsResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5526,6 +5560,13 @@ func ParseCreateChannelResponse(rsp *http.Response) (*CreateChannelResponse, err } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5566,6 +5607,13 @@ func ParseGetChannelResponse(rsp *http.Response) (*GetChannelResponse, error) { } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5620,6 +5668,13 @@ func ParseUpdateChannelResponse(rsp *http.Response) (*UpdateChannelResponse, err } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5674,6 +5729,13 @@ func ParseListChannelEventsResponse(rsp *http.Response) (*ListChannelEventsRespo } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5728,6 +5790,13 @@ func ParseSearchChannelEventsResponse(rsp *http.Response) (*SearchChannelEventsR } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5775,6 +5844,13 @@ func ParseGetChannelEventResponse(rsp *http.Response) (*GetChannelEventResponse, } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5822,6 +5898,13 @@ func ParseListOperationsResponse(rsp *http.Response) (*ListOperationsResponse, e } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5876,6 +5959,13 @@ func ParseCreateOperationResponse(rsp *http.Response) (*CreateOperationResponse, } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5923,6 +6013,13 @@ func ParseGetOperationResponse(rsp *http.Response) (*GetOperationResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5977,6 +6074,13 @@ func ParseFinalizeOrCancelOperationResponse(rsp *http.Response) (*FinalizeOrCanc } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6024,6 +6128,13 @@ func ParseListQueriesResponse(rsp *http.Response) (*ListQueriesResponse, error) } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6078,6 +6189,13 @@ func ParseCreateQueryResponse(rsp *http.Response) (*CreateQueryResponse, error) } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6139,6 +6257,13 @@ func ParseGetQueryResponse(rsp *http.Response) (*GetQueryResponse, error) { } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6186,6 +6311,13 @@ func ParseListWatchersResponse(rsp *http.Response) (*ListWatchersResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6240,6 +6372,13 @@ func ParseCreateWatcherResponse(rsp *http.Response) (*CreateWatcherResponse, err } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6287,6 +6426,13 @@ func ParseGetWatcherResponse(rsp *http.Response) (*GetWatcherResponse, error) { } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6348,6 +6494,13 @@ func ParseUpdateWatcherResponse(rsp *http.Response) (*UpdateWatcherResponse, err } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6421,6 +6574,13 @@ func ParseListNetworksResponse(rsp *http.Response) (*ListNetworksResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6461,6 +6621,13 @@ func ParseListWalletsResponse(rsp *http.Response) (*ListWalletsResponse, error) } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6508,6 +6675,13 @@ func ParseCreateWalletResponse(rsp *http.Response) (*CreateWalletResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6548,6 +6722,13 @@ func ParseGetWalletResponse(rsp *http.Response) (*GetWalletResponse, error) { } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -6602,6 +6783,13 @@ func ParseUpdateWalletResponse(rsp *http.Response) (*UpdateWalletResponse, error } response.JSON401 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest PermissionDenied + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest ApplicationError if err := json.Unmarshal(bodyBytes, &dest); err != nil { diff --git a/cmd/gen-permissions/main.go b/cmd/gen-permissions/main.go new file mode 100644 index 0000000..2b714d8 --- /dev/null +++ b/cmd/gen-permissions/main.go @@ -0,0 +1,253 @@ +// Command gen-permissions derives the route -> RBAC permissions map from the +// OpenAPI spec and writes it as Go source. +// +// Every operation in the spec must declare the permission(s) it requires via +// the x-permissions extension, e.g. +// +// post: +// x-permissions: [crec:wallet:create] +// +// An operation may list more than one permission, in which case a caller must +// hold at least one of them (OR, not AND) — list more than one when the +// endpoint is reachable via distinct, independently-grantable permissions, +// not to require several at once. +// +// Operations that intentionally require no permission (pre-auth infrastructure +// such as the health check) must be listed in exemptRoutes below, and should +// declare `x-permissions: []` in the spec to document the exemption (vacuum's +// x-permissions-required rule treats a missing key and an empty list +// differently — only the former is an error). Anything that is neither +// annotated nor exempt fails generation, so a new endpoint cannot ship +// unguarded by accident. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "go/format" + "os" + "regexp" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +const permissionExtension = "x-permissions" + +// exemptRoutes are operations that deliberately require no permission because +// they are served before an auth context exists. Adding to this list is a +// security-relevant change and should be reviewed as such. +var exemptRoutes = map[string]struct{}{ + "GET /health-check": {}, +} + +func main() { + if len(os.Args) != 4 { + fatalf("usage: gen-permissions ") + } + specPath, outPath, pkg := os.Args[1], os.Args[2], os.Args[3] + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(specPath) + if err != nil { + fatalf("loading %s: %v", specPath, err) + } + + perms := map[string][]string{} + var problems []string + + for _, path := range sortedPaths(doc) { + item := doc.Paths.Value(path) + for method, op := range item.Operations() { + // Keyed in gin's route syntax (:id), not OpenAPI's ({id}), since the + // only consumer is gin's own c.FullPath() in the permission middleware. + route := method + " " + toGinPath(path) + if _, exempt := exemptRoutes[route]; exempt { + if raw, annotated := op.Extensions[permissionExtension]; annotated { + // An explicit x-permissions: [] documents the exemption and is + // fine; a non-empty value is a genuine conflict with exemptRoutes. + values, err := parsePermissions(raw) + if err != nil || len(values) > 0 { + problems = append(problems, fmt.Sprintf( + "%s is in exemptRoutes but also declares a non-empty %s — remove one", route, permissionExtension)) + } + } + continue + } + + raw, ok := op.Extensions[permissionExtension] + if !ok { + problems = append(problems, fmt.Sprintf( + "%s is missing %s (add it, or add the route to exemptRoutes)", route, permissionExtension)) + continue + } + + values, err := parsePermissions(raw) + if err != nil { + problems = append(problems, fmt.Sprintf("%s: %s: %v", route, permissionExtension, err)) + continue + } + if len(values) == 0 { + problems = append(problems, fmt.Sprintf("%s: %s is empty", route, permissionExtension)) + continue + } + var bad bool + for _, v := range values { + if err := validatePermission(v); err != nil { + problems = append(problems, fmt.Sprintf("%s: %v", route, err)) + bad = true + } + } + if bad { + continue + } + perms[route] = values + } + } + + if len(problems) > 0 { + sort.Strings(problems) + fmt.Fprintf(os.Stderr, "gen-permissions: %d problem(s) in %s:\n", len(problems), specPath) + for _, p := range problems { + fmt.Fprintf(os.Stderr, " - %s\n", p) + } + os.Exit(1) + } + + src, err := render(pkg, specPath, perms) + if err != nil { + fatalf("rendering output: %v", err) + } + if err := os.WriteFile(outPath, src, 0o644); err != nil { + fatalf("writing %s: %v", outPath, err) + } + fmt.Printf("gen-permissions: wrote %s (%d routes, %d exempt)\n", outPath, len(perms), len(exemptRoutes)) +} + +// parsePermissions accepts the shapes the OpenAPI loader may produce for an +// extension value: a JSON array, a Go slice, or a bare string. +func parsePermissions(raw any) ([]string, error) { + switch v := raw.(type) { + case []string: + return v, nil + case string: + return []string{v}, nil + case []any: + out := make([]string, 0, len(v)) + for _, e := range v { + s, ok := e.(string) + if !ok { + return nil, fmt.Errorf("expected strings, got %T", e) + } + out = append(out, s) + } + return out, nil + case json.RawMessage: + var out []string + if err := json.Unmarshal(v, &out); err != nil { + var single string + if err2 := json.Unmarshal(v, &single); err2 == nil { + return []string{single}, nil + } + return nil, err + } + return out, nil + default: + return nil, fmt.Errorf("unsupported value type %T", raw) + } +} + +// validatePermission enforces the crec:: shape so a +// typo becomes a build failure rather than a permanent 403 at runtime. +func validatePermission(p string) error { + parts := strings.Split(p, ":") + if len(parts) != 3 { + return fmt.Errorf("permission %q must have the form crec::", p) + } + if parts[0] != "crec" { + return fmt.Errorf("permission %q must start with the crec namespace", p) + } + for i, part := range parts { + if part == "" { + return fmt.Errorf("permission %q has an empty segment %d", p, i+1) + } + } + return nil +} + +var openAPIPathParam = regexp.MustCompile(`\{([^}]+)\}`) + +// toGinPath converts an OpenAPI templated path ("/foo/{id}") to gin's route +// syntax ("/foo/:id"), matching what c.FullPath() returns at request time. +func toGinPath(path string) string { + return openAPIPathParam.ReplaceAllString(path, ":$1") +} + +func sortedPaths(doc *openapi3.T) []string { + paths := make([]string, 0, doc.Paths.Len()) + for path := range doc.Paths.Map() { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func render(pkg, specPath string, perms map[string][]string) ([]byte, error) { + routes := make([]string, 0, len(perms)) + for route := range perms { + routes = append(routes, route) + } + sort.Strings(routes) + + exempt := make([]string, 0, len(exemptRoutes)) + for route := range exemptRoutes { + exempt = append(exempt, route) + } + sort.Strings(exempt) + + var b bytes.Buffer + fmt.Fprintf(&b, "// Code generated by cmd/gen-permissions from %s. DO NOT EDIT.\n\n", specPath) + fmt.Fprintf(&b, "package %s\n\n", pkg) + b.WriteString("// RoutePermissions maps \"METHOD \" to the crec:* permissions\n") + b.WriteString("// the route requires; a caller must hold at least one of them (OR, not AND).\n") + b.WriteString("// Paths use gin's :id syntax, matching gin.Context.FullPath(), not OpenAPI's\n") + b.WriteString("// {id}.\n") + b.WriteString("var RoutePermissions = map[string][]string{\n") + for _, route := range routes { + fmt.Fprintf(&b, "\t%q: {", route) + for _, p := range perms[route] { + fmt.Fprintf(&b, "%q, ", p) + } + b.WriteString("},\n") + } + b.WriteString("}\n\n") + b.WriteString("// ExemptRoutes require no permission because they are served before an auth\n") + b.WriteString("// context exists.\n") + b.WriteString("var ExemptRoutes = map[string]struct{}{\n") + for _, route := range exempt { + fmt.Fprintf(&b, "\t%q: {},\n", route) + } + b.WriteString("}\n\n") + b.WriteString("// RequiredPermissions returns the permissions a route requires; the caller must\n") + b.WriteString("// hold at least one of them (OR, not AND). The second result is false when the\n") + b.WriteString("// route is exempt or unknown; callers must treat unknown routes as denied.\n") + b.WriteString("func RequiredPermissions(method, path string) ([]string, bool) {\n") + b.WriteString("\tperms, ok := RoutePermissions[method+\" \"+path]\n") + b.WriteString("\treturn perms, ok\n") + b.WriteString("}\n\n") + b.WriteString("// IsExempt reports whether a route is exempt from permission checks.\n") + b.WriteString("func IsExempt(method, path string) bool {\n") + b.WriteString("\t_, ok := ExemptRoutes[method+\" \"+path]\n") + b.WriteString("\treturn ok\n") + b.WriteString("}\n") + + return format.Source(b.Bytes()) +} + +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "gen-permissions: "+format+"\n", args...) + os.Exit(1) +} diff --git a/generate.go b/generate.go index 3eec22c..0c15208 100644 --- a/generate.go +++ b/generate.go @@ -4,3 +4,4 @@ package generate //go:generate go tool oapi-codegen -config stdserver/oapi-cfg.yaml ./api/openapi.yaml //go:generate go tool oapi-codegen -config client/oapi-cfg.yaml ./api/openapi.yaml //go:generate go tool oapi-codegen -config models/oapi-cfg.yaml ./models/models.yaml +//go:generate go run ./cmd/gen-permissions ./api/openapi.yaml ./permissions/permissions.gen.go permissions diff --git a/ginserver/ginserver.gen.go b/ginserver/ginserver.gen.go index 3e9483c..13faecb 100644 --- a/ginserver/ginserver.gen.go +++ b/ginserver/ginserver.gen.go @@ -26,6 +26,7 @@ const ( INTERNALERROR ApplicationErrorType = "INTERNAL_ERROR" NOTFOUND ApplicationErrorType = "NOT_FOUND" ORGANIZATIONNOTFOUND ApplicationErrorType = "ORGANIZATION_NOT_FOUND" + PERMISSIONDENIED ApplicationErrorType = "PERMISSION_DENIED" VALIDATIONERROR ApplicationErrorType = "VALIDATION_ERROR" ) @@ -1139,6 +1140,9 @@ type WatcherSummary struct { // OrganizationNotFound Standard error response body. type OrganizationNotFound = ApplicationError +// PermissionDenied Standard error response body. +type PermissionDenied = ApplicationError + // ListChannelsParams defines parameters for ListChannels. type ListChannelsParams struct { // Name Filter channels by name (case-insensitive partial match) @@ -3175,6 +3179,8 @@ func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options type OrganizationNotFoundJSONResponse ApplicationError +type PermissionDeniedJSONResponse ApplicationError + type ListChannelsRequestObject struct { Params ListChannelsParams } @@ -3203,6 +3209,15 @@ func (response ListChannels401JSONResponse) VisitListChannelsResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type ListChannels403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListChannels403JSONResponse) VisitListChannelsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListChannels500JSONResponse ApplicationError func (response ListChannels500JSONResponse) VisitListChannelsResponse(w http.ResponseWriter) error { @@ -3249,6 +3264,15 @@ func (response CreateChannel401JSONResponse) VisitCreateChannelResponse(w http.R return json.NewEncoder(w).Encode(response) } +type CreateChannel403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response CreateChannel403JSONResponse) VisitCreateChannelResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type CreateChannel500JSONResponse ApplicationError func (response CreateChannel500JSONResponse) VisitCreateChannelResponse(w http.ResponseWriter) error { @@ -3286,6 +3310,15 @@ func (response GetChannel401JSONResponse) VisitGetChannelResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type GetChannel403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response GetChannel403JSONResponse) VisitGetChannelResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetChannel404JSONResponse ApplicationError func (response GetChannel404JSONResponse) VisitGetChannelResponse(w http.ResponseWriter) error { @@ -3342,6 +3375,15 @@ func (response UpdateChannel401JSONResponse) VisitUpdateChannelResponse(w http.R return json.NewEncoder(w).Encode(response) } +type UpdateChannel403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response UpdateChannel403JSONResponse) VisitUpdateChannelResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type UpdateChannel404JSONResponse ApplicationError func (response UpdateChannel404JSONResponse) VisitUpdateChannelResponse(w http.ResponseWriter) error { @@ -3398,6 +3440,15 @@ func (response ListChannelEvents401JSONResponse) VisitListChannelEventsResponse( return json.NewEncoder(w).Encode(response) } +type ListChannelEvents403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListChannelEvents403JSONResponse) VisitListChannelEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListChannelEvents404JSONResponse ApplicationError func (response ListChannelEvents404JSONResponse) VisitListChannelEventsResponse(w http.ResponseWriter) error { @@ -3454,6 +3505,15 @@ func (response SearchChannelEvents401JSONResponse) VisitSearchChannelEventsRespo return json.NewEncoder(w).Encode(response) } +type SearchChannelEvents403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response SearchChannelEvents403JSONResponse) VisitSearchChannelEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type SearchChannelEvents404JSONResponse ApplicationError func (response SearchChannelEvents404JSONResponse) VisitSearchChannelEventsResponse(w http.ResponseWriter) error { @@ -3501,6 +3561,15 @@ func (response GetChannelEvent401JSONResponse) VisitGetChannelEventResponse(w ht return json.NewEncoder(w).Encode(response) } +type GetChannelEvent403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response GetChannelEvent403JSONResponse) VisitGetChannelEventResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetChannelEvent404JSONResponse ApplicationError func (response GetChannelEvent404JSONResponse) VisitGetChannelEventResponse(w http.ResponseWriter) error { @@ -3548,6 +3617,15 @@ func (response ListOperations401JSONResponse) VisitListOperationsResponse(w http return json.NewEncoder(w).Encode(response) } +type ListOperations403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListOperations403JSONResponse) VisitListOperationsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListOperations404JSONResponse ApplicationError func (response ListOperations404JSONResponse) VisitListOperationsResponse(w http.ResponseWriter) error { @@ -3604,6 +3682,15 @@ func (response CreateOperation401JSONResponse) VisitCreateOperationResponse(w ht return json.NewEncoder(w).Encode(response) } +type CreateOperation403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response CreateOperation403JSONResponse) VisitCreateOperationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type CreateOperation404JSONResponse ApplicationError func (response CreateOperation404JSONResponse) VisitCreateOperationResponse(w http.ResponseWriter) error { @@ -3651,6 +3738,15 @@ func (response GetOperation401JSONResponse) VisitGetOperationResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type GetOperation403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response GetOperation403JSONResponse) VisitGetOperationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetOperation404JSONResponse ApplicationError func (response GetOperation404JSONResponse) VisitGetOperationResponse(w http.ResponseWriter) error { @@ -3708,6 +3804,15 @@ func (response FinalizeOrCancelOperation401JSONResponse) VisitFinalizeOrCancelOp return json.NewEncoder(w).Encode(response) } +type FinalizeOrCancelOperation403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response FinalizeOrCancelOperation403JSONResponse) VisitFinalizeOrCancelOperationResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type FinalizeOrCancelOperation404JSONResponse ApplicationError func (response FinalizeOrCancelOperation404JSONResponse) VisitFinalizeOrCancelOperationResponse(w http.ResponseWriter) error { @@ -3755,6 +3860,15 @@ func (response ListQueries401JSONResponse) VisitListQueriesResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type ListQueries403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListQueries403JSONResponse) VisitListQueriesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListQueries404JSONResponse ApplicationError func (response ListQueries404JSONResponse) VisitListQueriesResponse(w http.ResponseWriter) error { @@ -3811,6 +3925,15 @@ func (response CreateQuery401JSONResponse) VisitCreateQueryResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type CreateQuery403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response CreateQuery403JSONResponse) VisitCreateQueryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type CreateQuery404JSONResponse ApplicationError func (response CreateQuery404JSONResponse) VisitCreateQueryResponse(w http.ResponseWriter) error { @@ -3876,6 +3999,15 @@ func (response GetQuery401JSONResponse) VisitGetQueryResponse(w http.ResponseWri return json.NewEncoder(w).Encode(response) } +type GetQuery403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response GetQuery403JSONResponse) VisitGetQueryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetQuery404JSONResponse ApplicationError func (response GetQuery404JSONResponse) VisitGetQueryResponse(w http.ResponseWriter) error { @@ -3923,6 +4055,15 @@ func (response ListWatchers401JSONResponse) VisitListWatchersResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type ListWatchers403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListWatchers403JSONResponse) VisitListWatchersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListWatchers404JSONResponse ApplicationError func (response ListWatchers404JSONResponse) VisitListWatchersResponse(w http.ResponseWriter) error { @@ -3979,6 +4120,15 @@ func (response CreateWatcher401JSONResponse) VisitCreateWatcherResponse(w http.R return json.NewEncoder(w).Encode(response) } +type CreateWatcher403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response CreateWatcher403JSONResponse) VisitCreateWatcherResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type CreateWatcher404JSONResponse ApplicationError func (response CreateWatcher404JSONResponse) VisitCreateWatcherResponse(w http.ResponseWriter) error { @@ -4026,6 +4176,15 @@ func (response GetWatcher401JSONResponse) VisitGetWatcherResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type GetWatcher403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response GetWatcher403JSONResponse) VisitGetWatcherResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetWatcher404JSONResponse ApplicationError func (response GetWatcher404JSONResponse) VisitGetWatcherResponse(w http.ResponseWriter) error { @@ -4092,6 +4251,15 @@ func (response UpdateWatcher401JSONResponse) VisitUpdateWatcherResponse(w http.R return json.NewEncoder(w).Encode(response) } +type UpdateWatcher403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response UpdateWatcher403JSONResponse) VisitUpdateWatcherResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type UpdateWatcher404JSONResponse ApplicationError func (response UpdateWatcher404JSONResponse) VisitUpdateWatcherResponse(w http.ResponseWriter) error { @@ -4153,6 +4321,15 @@ func (response ListNetworks401JSONResponse) VisitListNetworksResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type ListNetworks403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListNetworks403JSONResponse) VisitListNetworksResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListNetworks500JSONResponse ApplicationError func (response ListNetworks500JSONResponse) VisitListNetworksResponse(w http.ResponseWriter) error { @@ -4190,6 +4367,15 @@ func (response ListWallets401JSONResponse) VisitListWalletsResponse(w http.Respo return json.NewEncoder(w).Encode(response) } +type ListWallets403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response ListWallets403JSONResponse) VisitListWalletsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type ListWallets500JSONResponse ApplicationError func (response ListWallets500JSONResponse) VisitListWalletsResponse(w http.ResponseWriter) error { @@ -4236,6 +4422,15 @@ func (response CreateWallet401JSONResponse) VisitCreateWalletResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type CreateWallet403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response CreateWallet403JSONResponse) VisitCreateWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type CreateWallet500JSONResponse ApplicationError func (response CreateWallet500JSONResponse) VisitCreateWalletResponse(w http.ResponseWriter) error { @@ -4273,6 +4468,15 @@ func (response GetWallet401JSONResponse) VisitGetWalletResponse(w http.ResponseW return json.NewEncoder(w).Encode(response) } +type GetWallet403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response GetWallet403JSONResponse) VisitGetWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type GetWallet404JSONResponse ApplicationError func (response GetWallet404JSONResponse) VisitGetWalletResponse(w http.ResponseWriter) error { @@ -4329,6 +4533,15 @@ func (response UpdateWallet401JSONResponse) VisitUpdateWalletResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type UpdateWallet403JSONResponse struct{ PermissionDeniedJSONResponse } + +func (response UpdateWallet403JSONResponse) VisitUpdateWalletResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + type UpdateWallet404JSONResponse ApplicationError func (response UpdateWallet404JSONResponse) VisitUpdateWalletResponse(w http.ResponseWriter) error { diff --git a/go.mod b/go.mod index 671b60d..3545ad0 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/smartcontractkit/crec-api-go go 1.26.3 require ( + github.com/getkin/kin-openapi v0.132.0 github.com/gin-gonic/gin v1.10.1 github.com/oapi-codegen/runtime v1.1.2 + github.com/stretchr/testify v1.10.0 ) require ( @@ -13,10 +15,10 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/getkin/kin-openapi v0.132.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -39,6 +41,7 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/permissions/permissions.gen.go b/permissions/permissions.gen.go new file mode 100644 index 0000000..a29b87f --- /dev/null +++ b/permissions/permissions.gen.go @@ -0,0 +1,53 @@ +// Code generated by cmd/gen-permissions from ./api/openapi.yaml. DO NOT EDIT. + +package permissions + +// RoutePermissions maps "METHOD " to the crec:* permissions +// the route requires; a caller must hold at least one of them (OR, not AND). +// Paths use gin's :id syntax, matching gin.Context.FullPath(), not OpenAPI's +// {id}. +var RoutePermissions = map[string][]string{ + "GET /channels": {"crec:channel:read"}, + "GET /channels/:channel_id": {"crec:channel:read"}, + "GET /channels/:channel_id/events": {"crec:event:read"}, + "GET /channels/:channel_id/events/search": {"crec:event:read"}, + "GET /channels/:channel_id/events/search/:event_id": {"crec:event:read"}, + "GET /channels/:channel_id/operations": {"crec:operation:read"}, + "GET /channels/:channel_id/operations/:operation_id": {"crec:operation:read"}, + "GET /channels/:channel_id/queries": {"crec:query:read"}, + "GET /channels/:channel_id/queries/:query_id": {"crec:query:read"}, + "GET /channels/:channel_id/watchers": {"crec:watcher:read"}, + "GET /channels/:channel_id/watchers/:watcher_id": {"crec:watcher:read"}, + "GET /networks": {"crec:network:read"}, + "GET /wallets": {"crec:wallet:read"}, + "GET /wallets/:wallet_id": {"crec:wallet:read"}, + "PATCH /channels/:channel_id": {"crec:channel:update", "crec:channel:archive"}, + "PATCH /channels/:channel_id/operations/:operation_id": {"crec:operation:sign"}, + "PATCH /channels/:channel_id/watchers/:watcher_id": {"crec:watcher:update", "crec:watcher:archive"}, + "PATCH /wallets/:wallet_id": {"crec:wallet:update", "crec:wallet:archive"}, + "POST /channels": {"crec:channel:create"}, + "POST /channels/:channel_id/operations": {"crec:operation:create"}, + "POST /channels/:channel_id/queries": {"crec:query:create"}, + "POST /channels/:channel_id/watchers": {"crec:watcher:create"}, + "POST /wallets": {"crec:wallet:create"}, +} + +// ExemptRoutes require no permission because they are served before an auth +// context exists. +var ExemptRoutes = map[string]struct{}{ + "GET /health-check": {}, +} + +// RequiredPermissions returns the permissions a route requires; the caller must +// hold at least one of them (OR, not AND). The second result is false when the +// route is exempt or unknown; callers must treat unknown routes as denied. +func RequiredPermissions(method, path string) ([]string, bool) { + perms, ok := RoutePermissions[method+" "+path] + return perms, ok +} + +// IsExempt reports whether a route is exempt from permission checks. +func IsExempt(method, path string) bool { + _, ok := ExemptRoutes[method+" "+path] + return ok +} diff --git a/permissions/permissions_test.go b/permissions/permissions_test.go new file mode 100644 index 0000000..c25053c --- /dev/null +++ b/permissions/permissions_test.go @@ -0,0 +1,83 @@ +package permissions + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// The generator enforces these invariants at generation time; asserting them +// here keeps them true for anyone reading or hand-editing the generated file, +// and fails the unit-test job as well as the generate job. + +func TestRoutePermissionsAreWellFormed(t *testing.T) { + require.NotEmpty(t, RoutePermissions, "generation did not run") + + for route, perms := range RoutePermissions { + method, path, ok := strings.Cut(route, " ") + require.True(t, ok && method != "" && strings.HasPrefix(path, "/"), "route key %q is not %q", route, "METHOD /path") + require.NotContains(t, path, "{", "%s: path uses OpenAPI templating, not gin's :id syntax", route) + require.NotEmpty(t, perms, "%s: has no permissions", route) + + for _, perm := range perms { + parts := strings.Split(perm, ":") + require.Len(t, parts, 3, "%s: permission %q is not crec::", route, perm) + require.Equal(t, "crec", parts[0], "%s: permission %q is not in the crec namespace", route, perm) + for i, part := range parts { + require.NotEmpty(t, part, "%s: permission %q has an empty segment %d", route, perm, i+1) + } + } + } +} + +func TestExemptRoutesAreNotAlsoPermissioned(t *testing.T) { + for route := range ExemptRoutes { + perms, ok := RoutePermissions[route] + require.False(t, ok, "%s is exempt but also requires %q", route, perms) + } +} + +func TestRequiredPermissionsLookup(t *testing.T) { + // A route that must exist, to catch the map being keyed in an unexpected shape. + perms, ok := RequiredPermissions("POST", "/wallets") + require.True(t, ok, "POST /wallets not found in RoutePermissions") + require.Equal(t, []string{"crec:wallet:create"}, perms) + + // Unknown routes must report false so callers deny by default. + _, ok = RequiredPermissions("POST", "/does-not-exist") + require.False(t, ok, "unknown route reported a permission") + + require.True(t, IsExempt("GET", "/health-check"), "health check should be exempt") + require.False(t, IsExempt("POST", "/wallets"), "POST /wallets should not be exempt") +} + +func TestRequiredPermissionsLookupUsesGinPathSyntax(t *testing.T) { + // Templated routes must be keyed with gin's :id syntax, matching what + // gin.Context.FullPath() returns, not OpenAPI's {id}. + perms, ok := RequiredPermissions("PATCH", "/channels/:channel_id/operations/:operation_id") + require.True(t, ok, "gin-style route not found in RoutePermissions") + require.Equal(t, []string{"crec:operation:sign"}, perms) + + _, ok = RequiredPermissions("PATCH", "/channels/{channel_id}/operations/{operation_id}") + require.False(t, ok, "OpenAPI-style path should not resolve") +} + +func TestBodyDiscriminatedEndpointsListMultiplePermissions(t *testing.T) { + // Body-discriminated PATCH endpoints list both update and archive permissions; + // the middleware checks at least one, the controller makes the final selection. + perms, ok := RequiredPermissions("PATCH", "/wallets/:wallet_id") + require.True(t, ok) + require.Contains(t, perms, "crec:wallet:update") + require.Contains(t, perms, "crec:wallet:archive") + + perms, ok = RequiredPermissions("PATCH", "/channels/:channel_id") + require.True(t, ok) + require.Contains(t, perms, "crec:channel:update") + require.Contains(t, perms, "crec:channel:archive") + + perms, ok = RequiredPermissions("PATCH", "/channels/:channel_id/watchers/:watcher_id") + require.True(t, ok) + require.Contains(t, perms, "crec:watcher:update") + require.Contains(t, perms, "crec:watcher:archive") +} diff --git a/stdserver/stdserver.gen.go b/stdserver/stdserver.gen.go index 5a98716..a19c676 100644 --- a/stdserver/stdserver.gen.go +++ b/stdserver/stdserver.gen.go @@ -26,6 +26,7 @@ const ( INTERNALERROR ApplicationErrorType = "INTERNAL_ERROR" NOTFOUND ApplicationErrorType = "NOT_FOUND" ORGANIZATIONNOTFOUND ApplicationErrorType = "ORGANIZATION_NOT_FOUND" + PERMISSIONDENIED ApplicationErrorType = "PERMISSION_DENIED" VALIDATIONERROR ApplicationErrorType = "VALIDATION_ERROR" ) @@ -1139,6 +1140,9 @@ type WatcherSummary struct { // OrganizationNotFound Standard error response body. type OrganizationNotFound = ApplicationError +// PermissionDenied Standard error response body. +type PermissionDenied = ApplicationError + // ListChannelsParams defines parameters for ListChannels. type ListChannelsParams struct { // Name Filter channels by name (case-insensitive partial match) From d36ff035cfb9d95b4e2ab3817e9d724461f32b5f Mon Sep 17 00:00:00 2001 From: Lucas Fleischer Date: Tue, 11 Aug 2026 17:23:16 -0300 Subject: [PATCH 2/5] feat: remove deprecated permission linting and generator, update OpenAPI validation description --- .github/workflows/pull-request-main.yaml | 3 - Taskfile.yaml | 8 +- cmd/gen-permissions/main.go | 253 ----------------------- generate.go | 1 - go.mod | 5 +- permissions/permissions.gen.go | 53 ----- permissions/permissions_test.go | 83 -------- 7 files changed, 2 insertions(+), 404 deletions(-) delete mode 100644 cmd/gen-permissions/main.go delete mode 100644 permissions/permissions.gen.go delete mode 100644 permissions/permissions_test.go diff --git a/.github/workflows/pull-request-main.yaml b/.github/workflows/pull-request-main.yaml index 9ca0494..2bdecde 100644 --- a/.github/workflows/pull-request-main.yaml +++ b/.github/workflows/pull-request-main.yaml @@ -37,9 +37,6 @@ jobs: - name: Lint OpenAPI spec run: task validate-openapi - - name: Lint permissions - run: task lint:permissions - generate: name: Verify Code Generation is Up-to-Date runs-on: ubuntu-latest diff --git a/Taskfile.yaml b/Taskfile.yaml index a2451fb..afecaaa 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -27,12 +27,6 @@ tasks: validate-openapi: silent: true - desc: "Validate openapi spec" + desc: "Validate openapi spec (includes x-permissions check via vacuum ruleset)" cmds: - vacuum lint --ruleset .vacuum.yaml ./api/openapi.yaml - - lint:permissions: - silent: true - desc: "Verify every endpoint declares x-permissions" - cmds: - - go run ./cmd/gen-permissions ./api/openapi.yaml "$(mktemp)" permissions diff --git a/cmd/gen-permissions/main.go b/cmd/gen-permissions/main.go deleted file mode 100644 index 2b714d8..0000000 --- a/cmd/gen-permissions/main.go +++ /dev/null @@ -1,253 +0,0 @@ -// Command gen-permissions derives the route -> RBAC permissions map from the -// OpenAPI spec and writes it as Go source. -// -// Every operation in the spec must declare the permission(s) it requires via -// the x-permissions extension, e.g. -// -// post: -// x-permissions: [crec:wallet:create] -// -// An operation may list more than one permission, in which case a caller must -// hold at least one of them (OR, not AND) — list more than one when the -// endpoint is reachable via distinct, independently-grantable permissions, -// not to require several at once. -// -// Operations that intentionally require no permission (pre-auth infrastructure -// such as the health check) must be listed in exemptRoutes below, and should -// declare `x-permissions: []` in the spec to document the exemption (vacuum's -// x-permissions-required rule treats a missing key and an empty list -// differently — only the former is an error). Anything that is neither -// annotated nor exempt fails generation, so a new endpoint cannot ship -// unguarded by accident. -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "go/format" - "os" - "regexp" - "sort" - "strings" - - "github.com/getkin/kin-openapi/openapi3" -) - -const permissionExtension = "x-permissions" - -// exemptRoutes are operations that deliberately require no permission because -// they are served before an auth context exists. Adding to this list is a -// security-relevant change and should be reviewed as such. -var exemptRoutes = map[string]struct{}{ - "GET /health-check": {}, -} - -func main() { - if len(os.Args) != 4 { - fatalf("usage: gen-permissions ") - } - specPath, outPath, pkg := os.Args[1], os.Args[2], os.Args[3] - - loader := openapi3.NewLoader() - loader.IsExternalRefsAllowed = true - doc, err := loader.LoadFromFile(specPath) - if err != nil { - fatalf("loading %s: %v", specPath, err) - } - - perms := map[string][]string{} - var problems []string - - for _, path := range sortedPaths(doc) { - item := doc.Paths.Value(path) - for method, op := range item.Operations() { - // Keyed in gin's route syntax (:id), not OpenAPI's ({id}), since the - // only consumer is gin's own c.FullPath() in the permission middleware. - route := method + " " + toGinPath(path) - if _, exempt := exemptRoutes[route]; exempt { - if raw, annotated := op.Extensions[permissionExtension]; annotated { - // An explicit x-permissions: [] documents the exemption and is - // fine; a non-empty value is a genuine conflict with exemptRoutes. - values, err := parsePermissions(raw) - if err != nil || len(values) > 0 { - problems = append(problems, fmt.Sprintf( - "%s is in exemptRoutes but also declares a non-empty %s — remove one", route, permissionExtension)) - } - } - continue - } - - raw, ok := op.Extensions[permissionExtension] - if !ok { - problems = append(problems, fmt.Sprintf( - "%s is missing %s (add it, or add the route to exemptRoutes)", route, permissionExtension)) - continue - } - - values, err := parsePermissions(raw) - if err != nil { - problems = append(problems, fmt.Sprintf("%s: %s: %v", route, permissionExtension, err)) - continue - } - if len(values) == 0 { - problems = append(problems, fmt.Sprintf("%s: %s is empty", route, permissionExtension)) - continue - } - var bad bool - for _, v := range values { - if err := validatePermission(v); err != nil { - problems = append(problems, fmt.Sprintf("%s: %v", route, err)) - bad = true - } - } - if bad { - continue - } - perms[route] = values - } - } - - if len(problems) > 0 { - sort.Strings(problems) - fmt.Fprintf(os.Stderr, "gen-permissions: %d problem(s) in %s:\n", len(problems), specPath) - for _, p := range problems { - fmt.Fprintf(os.Stderr, " - %s\n", p) - } - os.Exit(1) - } - - src, err := render(pkg, specPath, perms) - if err != nil { - fatalf("rendering output: %v", err) - } - if err := os.WriteFile(outPath, src, 0o644); err != nil { - fatalf("writing %s: %v", outPath, err) - } - fmt.Printf("gen-permissions: wrote %s (%d routes, %d exempt)\n", outPath, len(perms), len(exemptRoutes)) -} - -// parsePermissions accepts the shapes the OpenAPI loader may produce for an -// extension value: a JSON array, a Go slice, or a bare string. -func parsePermissions(raw any) ([]string, error) { - switch v := raw.(type) { - case []string: - return v, nil - case string: - return []string{v}, nil - case []any: - out := make([]string, 0, len(v)) - for _, e := range v { - s, ok := e.(string) - if !ok { - return nil, fmt.Errorf("expected strings, got %T", e) - } - out = append(out, s) - } - return out, nil - case json.RawMessage: - var out []string - if err := json.Unmarshal(v, &out); err != nil { - var single string - if err2 := json.Unmarshal(v, &single); err2 == nil { - return []string{single}, nil - } - return nil, err - } - return out, nil - default: - return nil, fmt.Errorf("unsupported value type %T", raw) - } -} - -// validatePermission enforces the crec:: shape so a -// typo becomes a build failure rather than a permanent 403 at runtime. -func validatePermission(p string) error { - parts := strings.Split(p, ":") - if len(parts) != 3 { - return fmt.Errorf("permission %q must have the form crec::", p) - } - if parts[0] != "crec" { - return fmt.Errorf("permission %q must start with the crec namespace", p) - } - for i, part := range parts { - if part == "" { - return fmt.Errorf("permission %q has an empty segment %d", p, i+1) - } - } - return nil -} - -var openAPIPathParam = regexp.MustCompile(`\{([^}]+)\}`) - -// toGinPath converts an OpenAPI templated path ("/foo/{id}") to gin's route -// syntax ("/foo/:id"), matching what c.FullPath() returns at request time. -func toGinPath(path string) string { - return openAPIPathParam.ReplaceAllString(path, ":$1") -} - -func sortedPaths(doc *openapi3.T) []string { - paths := make([]string, 0, doc.Paths.Len()) - for path := range doc.Paths.Map() { - paths = append(paths, path) - } - sort.Strings(paths) - return paths -} - -func render(pkg, specPath string, perms map[string][]string) ([]byte, error) { - routes := make([]string, 0, len(perms)) - for route := range perms { - routes = append(routes, route) - } - sort.Strings(routes) - - exempt := make([]string, 0, len(exemptRoutes)) - for route := range exemptRoutes { - exempt = append(exempt, route) - } - sort.Strings(exempt) - - var b bytes.Buffer - fmt.Fprintf(&b, "// Code generated by cmd/gen-permissions from %s. DO NOT EDIT.\n\n", specPath) - fmt.Fprintf(&b, "package %s\n\n", pkg) - b.WriteString("// RoutePermissions maps \"METHOD \" to the crec:* permissions\n") - b.WriteString("// the route requires; a caller must hold at least one of them (OR, not AND).\n") - b.WriteString("// Paths use gin's :id syntax, matching gin.Context.FullPath(), not OpenAPI's\n") - b.WriteString("// {id}.\n") - b.WriteString("var RoutePermissions = map[string][]string{\n") - for _, route := range routes { - fmt.Fprintf(&b, "\t%q: {", route) - for _, p := range perms[route] { - fmt.Fprintf(&b, "%q, ", p) - } - b.WriteString("},\n") - } - b.WriteString("}\n\n") - b.WriteString("// ExemptRoutes require no permission because they are served before an auth\n") - b.WriteString("// context exists.\n") - b.WriteString("var ExemptRoutes = map[string]struct{}{\n") - for _, route := range exempt { - fmt.Fprintf(&b, "\t%q: {},\n", route) - } - b.WriteString("}\n\n") - b.WriteString("// RequiredPermissions returns the permissions a route requires; the caller must\n") - b.WriteString("// hold at least one of them (OR, not AND). The second result is false when the\n") - b.WriteString("// route is exempt or unknown; callers must treat unknown routes as denied.\n") - b.WriteString("func RequiredPermissions(method, path string) ([]string, bool) {\n") - b.WriteString("\tperms, ok := RoutePermissions[method+\" \"+path]\n") - b.WriteString("\treturn perms, ok\n") - b.WriteString("}\n\n") - b.WriteString("// IsExempt reports whether a route is exempt from permission checks.\n") - b.WriteString("func IsExempt(method, path string) bool {\n") - b.WriteString("\t_, ok := ExemptRoutes[method+\" \"+path]\n") - b.WriteString("\treturn ok\n") - b.WriteString("}\n") - - return format.Source(b.Bytes()) -} - -func fatalf(format string, args ...any) { - fmt.Fprintf(os.Stderr, "gen-permissions: "+format+"\n", args...) - os.Exit(1) -} diff --git a/generate.go b/generate.go index 0c15208..3eec22c 100644 --- a/generate.go +++ b/generate.go @@ -4,4 +4,3 @@ package generate //go:generate go tool oapi-codegen -config stdserver/oapi-cfg.yaml ./api/openapi.yaml //go:generate go tool oapi-codegen -config client/oapi-cfg.yaml ./api/openapi.yaml //go:generate go tool oapi-codegen -config models/oapi-cfg.yaml ./models/models.yaml -//go:generate go run ./cmd/gen-permissions ./api/openapi.yaml ./permissions/permissions.gen.go permissions diff --git a/go.mod b/go.mod index 3545ad0..671b60d 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,8 @@ module github.com/smartcontractkit/crec-api-go go 1.26.3 require ( - github.com/getkin/kin-openapi v0.132.0 github.com/gin-gonic/gin v1.10.1 github.com/oapi-codegen/runtime v1.1.2 - github.com/stretchr/testify v1.10.0 ) require ( @@ -15,10 +13,10 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/getkin/kin-openapi v0.132.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect @@ -41,7 +39,6 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/speakeasy-api/jsonpath v0.6.0 // indirect github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/permissions/permissions.gen.go b/permissions/permissions.gen.go deleted file mode 100644 index a29b87f..0000000 --- a/permissions/permissions.gen.go +++ /dev/null @@ -1,53 +0,0 @@ -// Code generated by cmd/gen-permissions from ./api/openapi.yaml. DO NOT EDIT. - -package permissions - -// RoutePermissions maps "METHOD " to the crec:* permissions -// the route requires; a caller must hold at least one of them (OR, not AND). -// Paths use gin's :id syntax, matching gin.Context.FullPath(), not OpenAPI's -// {id}. -var RoutePermissions = map[string][]string{ - "GET /channels": {"crec:channel:read"}, - "GET /channels/:channel_id": {"crec:channel:read"}, - "GET /channels/:channel_id/events": {"crec:event:read"}, - "GET /channels/:channel_id/events/search": {"crec:event:read"}, - "GET /channels/:channel_id/events/search/:event_id": {"crec:event:read"}, - "GET /channels/:channel_id/operations": {"crec:operation:read"}, - "GET /channels/:channel_id/operations/:operation_id": {"crec:operation:read"}, - "GET /channels/:channel_id/queries": {"crec:query:read"}, - "GET /channels/:channel_id/queries/:query_id": {"crec:query:read"}, - "GET /channels/:channel_id/watchers": {"crec:watcher:read"}, - "GET /channels/:channel_id/watchers/:watcher_id": {"crec:watcher:read"}, - "GET /networks": {"crec:network:read"}, - "GET /wallets": {"crec:wallet:read"}, - "GET /wallets/:wallet_id": {"crec:wallet:read"}, - "PATCH /channels/:channel_id": {"crec:channel:update", "crec:channel:archive"}, - "PATCH /channels/:channel_id/operations/:operation_id": {"crec:operation:sign"}, - "PATCH /channels/:channel_id/watchers/:watcher_id": {"crec:watcher:update", "crec:watcher:archive"}, - "PATCH /wallets/:wallet_id": {"crec:wallet:update", "crec:wallet:archive"}, - "POST /channels": {"crec:channel:create"}, - "POST /channels/:channel_id/operations": {"crec:operation:create"}, - "POST /channels/:channel_id/queries": {"crec:query:create"}, - "POST /channels/:channel_id/watchers": {"crec:watcher:create"}, - "POST /wallets": {"crec:wallet:create"}, -} - -// ExemptRoutes require no permission because they are served before an auth -// context exists. -var ExemptRoutes = map[string]struct{}{ - "GET /health-check": {}, -} - -// RequiredPermissions returns the permissions a route requires; the caller must -// hold at least one of them (OR, not AND). The second result is false when the -// route is exempt or unknown; callers must treat unknown routes as denied. -func RequiredPermissions(method, path string) ([]string, bool) { - perms, ok := RoutePermissions[method+" "+path] - return perms, ok -} - -// IsExempt reports whether a route is exempt from permission checks. -func IsExempt(method, path string) bool { - _, ok := ExemptRoutes[method+" "+path] - return ok -} diff --git a/permissions/permissions_test.go b/permissions/permissions_test.go deleted file mode 100644 index c25053c..0000000 --- a/permissions/permissions_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package permissions - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -// The generator enforces these invariants at generation time; asserting them -// here keeps them true for anyone reading or hand-editing the generated file, -// and fails the unit-test job as well as the generate job. - -func TestRoutePermissionsAreWellFormed(t *testing.T) { - require.NotEmpty(t, RoutePermissions, "generation did not run") - - for route, perms := range RoutePermissions { - method, path, ok := strings.Cut(route, " ") - require.True(t, ok && method != "" && strings.HasPrefix(path, "/"), "route key %q is not %q", route, "METHOD /path") - require.NotContains(t, path, "{", "%s: path uses OpenAPI templating, not gin's :id syntax", route) - require.NotEmpty(t, perms, "%s: has no permissions", route) - - for _, perm := range perms { - parts := strings.Split(perm, ":") - require.Len(t, parts, 3, "%s: permission %q is not crec::", route, perm) - require.Equal(t, "crec", parts[0], "%s: permission %q is not in the crec namespace", route, perm) - for i, part := range parts { - require.NotEmpty(t, part, "%s: permission %q has an empty segment %d", route, perm, i+1) - } - } - } -} - -func TestExemptRoutesAreNotAlsoPermissioned(t *testing.T) { - for route := range ExemptRoutes { - perms, ok := RoutePermissions[route] - require.False(t, ok, "%s is exempt but also requires %q", route, perms) - } -} - -func TestRequiredPermissionsLookup(t *testing.T) { - // A route that must exist, to catch the map being keyed in an unexpected shape. - perms, ok := RequiredPermissions("POST", "/wallets") - require.True(t, ok, "POST /wallets not found in RoutePermissions") - require.Equal(t, []string{"crec:wallet:create"}, perms) - - // Unknown routes must report false so callers deny by default. - _, ok = RequiredPermissions("POST", "/does-not-exist") - require.False(t, ok, "unknown route reported a permission") - - require.True(t, IsExempt("GET", "/health-check"), "health check should be exempt") - require.False(t, IsExempt("POST", "/wallets"), "POST /wallets should not be exempt") -} - -func TestRequiredPermissionsLookupUsesGinPathSyntax(t *testing.T) { - // Templated routes must be keyed with gin's :id syntax, matching what - // gin.Context.FullPath() returns, not OpenAPI's {id}. - perms, ok := RequiredPermissions("PATCH", "/channels/:channel_id/operations/:operation_id") - require.True(t, ok, "gin-style route not found in RoutePermissions") - require.Equal(t, []string{"crec:operation:sign"}, perms) - - _, ok = RequiredPermissions("PATCH", "/channels/{channel_id}/operations/{operation_id}") - require.False(t, ok, "OpenAPI-style path should not resolve") -} - -func TestBodyDiscriminatedEndpointsListMultiplePermissions(t *testing.T) { - // Body-discriminated PATCH endpoints list both update and archive permissions; - // the middleware checks at least one, the controller makes the final selection. - perms, ok := RequiredPermissions("PATCH", "/wallets/:wallet_id") - require.True(t, ok) - require.Contains(t, perms, "crec:wallet:update") - require.Contains(t, perms, "crec:wallet:archive") - - perms, ok = RequiredPermissions("PATCH", "/channels/:channel_id") - require.True(t, ok) - require.Contains(t, perms, "crec:channel:update") - require.Contains(t, perms, "crec:channel:archive") - - perms, ok = RequiredPermissions("PATCH", "/channels/:channel_id/watchers/:watcher_id") - require.True(t, ok) - require.Contains(t, perms, "crec:watcher:update") - require.Contains(t, perms, "crec:watcher:archive") -} From 9370122249dd1db680177409bcbf61c2133ef74b Mon Sep 17 00:00:00 2001 From: Lucas Fleischer Date: Wed, 12 Aug 2026 15:45:52 -0300 Subject: [PATCH 3/5] feat: update health check endpoint permissions format --- api/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 5e2d183..8fe5ea9 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -34,7 +34,7 @@ paths: tags: - Health operationId: getHealthCheck - x-permissions: [] # served before an auth context exists; see cmd/gen-permissions exemptRoutes + x-permissions: [] summary: Health check endpoint responses: "200": From 3b3d61ec8c746d0e30d30afcade9eb1c51c8cd23 Mon Sep 17 00:00:00 2001 From: Lucas Fleischer Date: Mon, 31 Aug 2026 16:57:48 -0300 Subject: [PATCH 4/5] feat: add Actor type and resource attribution fields to various models (#137) --- api/openapi.yaml | 89 +++++++++++++++++++++++++++++++++----- client/client.gen.go | 63 ++++++++++++++++++++++++--- ginserver/ginserver.gen.go | 63 ++++++++++++++++++++++++--- stdserver/stdserver.gen.go | 63 ++++++++++++++++++++++++--- 4 files changed, 248 insertions(+), 30 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 8fe5ea9..7648abd 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2046,6 +2046,10 @@ components: format: int64 description: Unix timestamp in seconds example: 1601528660 + Actor: + type: string + description: Resource attribution actor — ":" for a user, "api:" for an API key. + example: "usr_01J8K2M3N4P5Q6R7S8T9V0:jane@example.com" HealthCheck: description: Health check response. type: object @@ -2171,6 +2175,18 @@ components: $ref: "#/components/schemas/WalletConfiguration" created_at: $ref: "#/components/schemas/Timestamp" + updated_at: + $ref: "#/components/schemas/Timestamp" + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that created the resource. Null when there is no attributable subject (e.g. background job). + updated_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. required: - wallet_id - name @@ -2446,10 +2462,19 @@ components: status: $ref: "#/components/schemas/ChannelStatus" created_at: - type: integer - format: int64 - description: Timestamp of when the channel was created - example: 1715698800 + $ref: "#/components/schemas/Timestamp" + updated_at: + $ref: "#/components/schemas/Timestamp" + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that created the resource. Null when there is no attributable subject (e.g. background job). + updated_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. required: - channel_id - name @@ -2634,6 +2659,8 @@ components: signatures: ["0x789abc"] created_at: 1715698800 updated_at: 1715698815 + created_by: "usr_01J8K2M3N4P5Q6R7S8T9V0:jane@example.com" + updated_by: "usr_01J8K2M3N4P5Q6R7S8T9V0:jane@example.com" expires_at: 1715702400 accepted_at: 1715698800 sending_at: 1715698801 @@ -2670,6 +2697,16 @@ components: $ref: "#/components/schemas/Timestamp" updated_at: $ref: "#/components/schemas/Timestamp" + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that created the resource. Null when there is no attributable subject (e.g. background job). + updated_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. expires_at: $ref: "#/components/schemas/Timestamp" accepted_at: @@ -2872,10 +2909,19 @@ components: description: ID of the channel this watcher belongs to example: "7c9e6679-7425-40de-944b-e07fc1f90ae7" created_at: - type: integer - format: int64 - description: Timestamp of when the watcher was created - example: 1715698800 + $ref: "#/components/schemas/Timestamp" + updated_at: + $ref: "#/components/schemas/Timestamp" + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that created the resource. Null when there is no attributable subject (e.g. background job). + updated_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. status: $ref: "#/components/schemas/WatcherStatus" chain_selector: @@ -2941,10 +2987,19 @@ components: description: ID of the channel this watcher belongs to example: "7c9e6679-7425-40de-944b-e07fc1f90ae7" created_at: - type: integer - format: int64 - description: Timestamp of when the watcher was created - example: 1715698800 + $ref: "#/components/schemas/Timestamp" + updated_at: + $ref: "#/components/schemas/Timestamp" + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that created the resource. Null when there is no attributable subject (e.g. background job). + updated_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. status: $ref: "#/components/schemas/WatcherStatus" chain_selector: @@ -3086,6 +3141,16 @@ components: $ref: "#/components/schemas/Timestamp" updated_at: $ref: "#/components/schemas/Timestamp" + created_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that created the resource. Flat display-string form; initiator carries the structured Subject equivalent. Null when there is no attributable subject (e.g. background job). + updated_by: + nullable: true + allOf: + - $ref: "#/components/schemas/Actor" + description: Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. operation_writer_workflow_id: allOf: - $ref: "#/components/schemas/CREWorkflowId" diff --git a/client/client.gen.go b/client/client.gen.go index d020f6d..25fded5 100644 --- a/client/client.gen.go +++ b/client/client.gen.go @@ -164,6 +164,9 @@ const ( WatcherStatusPending WatcherStatus = "pending" ) +// Actor Resource attribution actor — ":" for a user, "api:" for an API key. +type Actor = string + // ApplicationError Standard error response body. type ApplicationError struct { // Code Machine-readable error code for NOT_FOUND and CONFLICT responses. @@ -220,8 +223,11 @@ type Channel struct { // ChannelId Unique identifier for the channel ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the channel was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // Description Description of the channel Description *string `json:"description,omitempty"` @@ -231,6 +237,12 @@ type Channel struct { // Status Status of a channel Status ChannelStatus `json:"status"` + + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` } // ChannelList Paginated list of channels. @@ -582,6 +594,9 @@ type Operation struct { // CreatedAt Unix timestamp in seconds CreatedAt Timestamp `json:"created_at"` + // CreatedBy Actor that created the resource. Flat display-string form; initiator carries the structured Subject equivalent. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // Deadline Unix timestamp deadline for the operation. A value of 0 means no expiration. Deadline int64 `json:"deadline"` @@ -620,6 +635,9 @@ type Operation struct { // UpdatedAt Unix timestamp in seconds UpdatedAt Timestamp `json:"updated_at"` + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WalletOperationId Unique wallet operation identifier WalletOperationId string `json:"wallet_operation_id"` } @@ -726,6 +744,9 @@ type Query struct { // CreatedAt Unix timestamp in seconds CreatedAt Timestamp `json:"created_at"` + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // EventHash Verifiable event hash for the terminal query result. EventHash *string `json:"event_hash,omitempty"` @@ -762,6 +783,9 @@ type Query struct { // UpdatedAt Unix timestamp in seconds UpdatedAt Timestamp `json:"updated_at"` + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // VerifiableResult Base64-encoded verifiable query result. VerifiableResult *string `json:"verifiable_result,omitempty"` @@ -952,6 +976,9 @@ type Wallet struct { // CreatedAt Unix timestamp in seconds CreatedAt *Timestamp `json:"created_at,omitempty"` + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // Description Description of the wallet Description *string `json:"description,omitempty"` @@ -964,6 +991,12 @@ type Wallet struct { // StatusChannelId Channel ID where wallet status events are published StatusChannelId *openapi_types.UUID `json:"status_channel_id,omitempty"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WalletId Unique identifier for the wallet WalletId openapi_types.UUID `json:"wallet_id"` @@ -1029,8 +1062,11 @@ type Watcher struct { // ChannelId ID of the channel this watcher belongs to ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the watcher was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // DonFamily DON family the watcher's workflow runs on (e.g., "zone-a"). Used to identify which DON nodes signed the events. DonFamily string `json:"don_family"` @@ -1050,6 +1086,12 @@ type Watcher struct { // Status Status of a watcher entity Status WatcherStatus `json:"status"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WatcherId Unique identifier for the watcher WatcherId openapi_types.UUID `json:"watcher_id"` @@ -1127,8 +1169,11 @@ type WatcherSummary struct { // ChannelId ID of the channel this watcher belongs to ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the watcher was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // DonFamily DON family the watcher's workflow runs on (e.g., "zone-a"). Used to identify which DON nodes signed the events. DonFamily string `json:"don_family"` @@ -1142,6 +1187,12 @@ type WatcherSummary struct { // Status Status of a watcher entity Status WatcherStatus `json:"status"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WatcherId Unique identifier for the watcher WatcherId openapi_types.UUID `json:"watcher_id"` diff --git a/ginserver/ginserver.gen.go b/ginserver/ginserver.gen.go index bdfbd4d..296c831 100644 --- a/ginserver/ginserver.gen.go +++ b/ginserver/ginserver.gen.go @@ -162,6 +162,9 @@ const ( WatcherStatusPending WatcherStatus = "pending" ) +// Actor Resource attribution actor — ":" for a user, "api:" for an API key. +type Actor = string + // ApplicationError Standard error response body. type ApplicationError struct { // Code Machine-readable error code for NOT_FOUND and CONFLICT responses. @@ -218,8 +221,11 @@ type Channel struct { // ChannelId Unique identifier for the channel ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the channel was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // Description Description of the channel Description *string `json:"description,omitempty"` @@ -229,6 +235,12 @@ type Channel struct { // Status Status of a channel Status ChannelStatus `json:"status"` + + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` } // ChannelList Paginated list of channels. @@ -580,6 +592,9 @@ type Operation struct { // CreatedAt Unix timestamp in seconds CreatedAt Timestamp `json:"created_at"` + // CreatedBy Actor that created the resource. Flat display-string form; initiator carries the structured Subject equivalent. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // Deadline Unix timestamp deadline for the operation. A value of 0 means no expiration. Deadline int64 `json:"deadline"` @@ -618,6 +633,9 @@ type Operation struct { // UpdatedAt Unix timestamp in seconds UpdatedAt Timestamp `json:"updated_at"` + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WalletOperationId Unique wallet operation identifier WalletOperationId string `json:"wallet_operation_id"` } @@ -724,6 +742,9 @@ type Query struct { // CreatedAt Unix timestamp in seconds CreatedAt Timestamp `json:"created_at"` + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // EventHash Verifiable event hash for the terminal query result. EventHash *string `json:"event_hash,omitempty"` @@ -760,6 +781,9 @@ type Query struct { // UpdatedAt Unix timestamp in seconds UpdatedAt Timestamp `json:"updated_at"` + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // VerifiableResult Base64-encoded verifiable query result. VerifiableResult *string `json:"verifiable_result,omitempty"` @@ -950,6 +974,9 @@ type Wallet struct { // CreatedAt Unix timestamp in seconds CreatedAt *Timestamp `json:"created_at,omitempty"` + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // Description Description of the wallet Description *string `json:"description,omitempty"` @@ -962,6 +989,12 @@ type Wallet struct { // StatusChannelId Channel ID where wallet status events are published StatusChannelId *openapi_types.UUID `json:"status_channel_id,omitempty"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WalletId Unique identifier for the wallet WalletId openapi_types.UUID `json:"wallet_id"` @@ -1027,8 +1060,11 @@ type Watcher struct { // ChannelId ID of the channel this watcher belongs to ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the watcher was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // DonFamily DON family the watcher's workflow runs on (e.g., "zone-a"). Used to identify which DON nodes signed the events. DonFamily string `json:"don_family"` @@ -1048,6 +1084,12 @@ type Watcher struct { // Status Status of a watcher entity Status WatcherStatus `json:"status"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WatcherId Unique identifier for the watcher WatcherId openapi_types.UUID `json:"watcher_id"` @@ -1125,8 +1167,11 @@ type WatcherSummary struct { // ChannelId ID of the channel this watcher belongs to ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the watcher was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // DonFamily DON family the watcher's workflow runs on (e.g., "zone-a"). Used to identify which DON nodes signed the events. DonFamily string `json:"don_family"` @@ -1140,6 +1185,12 @@ type WatcherSummary struct { // Status Status of a watcher entity Status WatcherStatus `json:"status"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WatcherId Unique identifier for the watcher WatcherId openapi_types.UUID `json:"watcher_id"` diff --git a/stdserver/stdserver.gen.go b/stdserver/stdserver.gen.go index 626644e..d15905a 100644 --- a/stdserver/stdserver.gen.go +++ b/stdserver/stdserver.gen.go @@ -162,6 +162,9 @@ const ( WatcherStatusPending WatcherStatus = "pending" ) +// Actor Resource attribution actor — ":" for a user, "api:" for an API key. +type Actor = string + // ApplicationError Standard error response body. type ApplicationError struct { // Code Machine-readable error code for NOT_FOUND and CONFLICT responses. @@ -218,8 +221,11 @@ type Channel struct { // ChannelId Unique identifier for the channel ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the channel was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // Description Description of the channel Description *string `json:"description,omitempty"` @@ -229,6 +235,12 @@ type Channel struct { // Status Status of a channel Status ChannelStatus `json:"status"` + + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` } // ChannelList Paginated list of channels. @@ -580,6 +592,9 @@ type Operation struct { // CreatedAt Unix timestamp in seconds CreatedAt Timestamp `json:"created_at"` + // CreatedBy Actor that created the resource. Flat display-string form; initiator carries the structured Subject equivalent. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // Deadline Unix timestamp deadline for the operation. A value of 0 means no expiration. Deadline int64 `json:"deadline"` @@ -618,6 +633,9 @@ type Operation struct { // UpdatedAt Unix timestamp in seconds UpdatedAt Timestamp `json:"updated_at"` + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WalletOperationId Unique wallet operation identifier WalletOperationId string `json:"wallet_operation_id"` } @@ -724,6 +742,9 @@ type Query struct { // CreatedAt Unix timestamp in seconds CreatedAt Timestamp `json:"created_at"` + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // EventHash Verifiable event hash for the terminal query result. EventHash *string `json:"event_hash,omitempty"` @@ -760,6 +781,9 @@ type Query struct { // UpdatedAt Unix timestamp in seconds UpdatedAt Timestamp `json:"updated_at"` + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // VerifiableResult Base64-encoded verifiable query result. VerifiableResult *string `json:"verifiable_result,omitempty"` @@ -950,6 +974,9 @@ type Wallet struct { // CreatedAt Unix timestamp in seconds CreatedAt *Timestamp `json:"created_at,omitempty"` + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` + // Description Description of the wallet Description *string `json:"description,omitempty"` @@ -962,6 +989,12 @@ type Wallet struct { // StatusChannelId Channel ID where wallet status events are published StatusChannelId *openapi_types.UUID `json:"status_channel_id,omitempty"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WalletId Unique identifier for the wallet WalletId openapi_types.UUID `json:"wallet_id"` @@ -1027,8 +1060,11 @@ type Watcher struct { // ChannelId ID of the channel this watcher belongs to ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the watcher was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // DonFamily DON family the watcher's workflow runs on (e.g., "zone-a"). Used to identify which DON nodes signed the events. DonFamily string `json:"don_family"` @@ -1048,6 +1084,12 @@ type Watcher struct { // Status Status of a watcher entity Status WatcherStatus `json:"status"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WatcherId Unique identifier for the watcher WatcherId openapi_types.UUID `json:"watcher_id"` @@ -1125,8 +1167,11 @@ type WatcherSummary struct { // ChannelId ID of the channel this watcher belongs to ChannelId openapi_types.UUID `json:"channel_id"` - // CreatedAt Timestamp of when the watcher was created - CreatedAt int64 `json:"created_at"` + // CreatedAt Unix timestamp in seconds + CreatedAt Timestamp `json:"created_at"` + + // CreatedBy Actor that created the resource. Null when there is no attributable subject (e.g. background job). + CreatedBy *Actor `json:"created_by"` // DonFamily DON family the watcher's workflow runs on (e.g., "zone-a"). Used to identify which DON nodes signed the events. DonFamily string `json:"don_family"` @@ -1140,6 +1185,12 @@ type WatcherSummary struct { // Status Status of a watcher entity Status WatcherStatus `json:"status"` + // UpdatedAt Unix timestamp in seconds + UpdatedAt *Timestamp `json:"updated_at,omitempty"` + + // UpdatedBy Actor that last updated the resource. Replaced on every actor-driven update; untouched by background updates. Null when there is no attributable subject. + UpdatedBy *Actor `json:"updated_by"` + // WatcherId Unique identifier for the watcher WatcherId openapi_types.UUID `json:"watcher_id"` From dd2bfeb297b7e3a9b382966385e7ae035cec154b Mon Sep 17 00:00:00 2001 From: Lucas Fleischer Date: Wed, 30 Sep 2026 14:37:57 -0300 Subject: [PATCH 5/5] feat: update x-permissions to use 'write' instead of 'create' for wallet, channel, query, and operation operations --- .vacuum.yaml | 2 +- api/openapi.yaml | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.vacuum.yaml b/.vacuum.yaml index 6649b82..c5b03df 100644 --- a/.vacuum.yaml +++ b/.vacuum.yaml @@ -40,4 +40,4 @@ rules: minItems: 0 items: type: string - pattern: "^crec:[^:]+:[^:]+$" + pattern: "^crec:[^:]+:[^:]+(:[^:]+)?$" diff --git a/api/openapi.yaml b/api/openapi.yaml index 674b856..5209d1d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -75,7 +75,7 @@ paths: tags: - Wallets operationId: createWallet - x-permissions: [crec:wallet:create] + x-permissions: [crec:wallet:write] summary: Creates a new wallet. requestBody: description: Wallet creation parameters. @@ -247,7 +247,7 @@ paths: tags: - Wallets operationId: updateWallet - x-permissions: [crec:wallet:update, crec:wallet:archive] + x-permissions: [crec:wallet:write, crec:wallet:archive:write] summary: Updates a wallet name. parameters: - in: path @@ -311,7 +311,7 @@ paths: tags: - Channels operationId: createChannel - x-permissions: [crec:channel:create] + x-permissions: [crec:channel:write] summary: Creates a new channel. requestBody: description: Channel creation parameters. @@ -458,7 +458,7 @@ paths: tags: - Channels operationId: updateChannel - x-permissions: [crec:channel:update, crec:channel:archive] + x-permissions: [crec:channel:write, crec:channel:archive:write] summary: Updates a channel. description: | Updates channel properties. Can be used to update the name, description, or to transition the channel status (e.g., to archived). @@ -525,7 +525,7 @@ paths: tags: - Queries operationId: createQuery - x-permissions: [crec:query:create] + x-permissions: [crec:query:write] summary: Creates a chain query under a channel. description: | Creates a channel-scoped Chain Query Request. New queries are rejected for archived channels. @@ -880,7 +880,7 @@ paths: tags: - Watchers operationId: createWatcher - x-permissions: [crec:watcher:create] + x-permissions: [crec:watcher:write] summary: Creates a watcher in a channel. parameters: - in: path @@ -1093,7 +1093,7 @@ paths: tags: - Watchers operationId: updateWatcher - x-permissions: [crec:watcher:update, crec:watcher:archive] + x-permissions: [crec:watcher:write, crec:watcher:archive:write] summary: Updates a watcher. description: | Updates watcher properties. Can be used to update the name or to transition the watcher status (e.g., to archived). @@ -1174,7 +1174,7 @@ paths: tags: - Operations operationId: createOperation - x-permissions: [crec:operation:create] + x-permissions: [crec:operation:write] summary: Creates a signed operation or draft request, scoped to a channel. description: | Creates a signed or draft operation first, depending on the presence of a signature. @@ -1427,7 +1427,7 @@ paths: tags: - Operations operationId: finalizeOrCancelOperation - x-permissions: [crec:operation:sign] + x-permissions: [crec:operation:signature:write] summary: Finalizes or cancels an operation. description: | Finalize by sending status accepted with signature and digest. @@ -1824,7 +1824,7 @@ components: application/json: schema: $ref: "#/components/schemas/ApplicationError" - example: {type: "PERMISSION_DENIED", message: "principal lacks permission crec:wallet:create"} + example: {type: "PERMISSION_DENIED", message: "principal lacks permission crec:wallet:write"} schemas: WalletStatus: type: string