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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ jobs:
if: needs.changes.outputs.shell == 'true'
run: go test ./...

- name: Build and test reference module
# The reference module proves the public SDK from a module author's
# side, so it is built, vetted, and tested whenever the shell or the
# module changes.
if: needs.changes.outputs.shell == 'true'
working-directory: examples/reference-module
shell: bash
run: |
set -euo pipefail

mapfile -t files < <(git ls-files '*.go')
unformatted="$(gofmt -l "${files[@]}")"
if [[ -n "$unformatted" ]]; then
echo "The following reference-module files require gofmt:"
echo "$unformatted"
exit 1
fi

go vet ./...
go test ./...

- name: Build SDK
if: needs.changes.outputs.sdk == 'true'
working-directory: sdk
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pins it to that single line.
go build ./... # shell
go test ./... # shell, including acceptance tests
(cd sdk && GOWORK=off go test ./...) # SDK without workspace composition
(cd examples/reference-module && go test ./...) # reference module
```

The shell, protocol, SDK, and module versions move independently and are
Expand Down
32 changes: 32 additions & 0 deletions docs/examples/authentication-contexts.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,38 @@ The variable names, token endpoint, and scopes are non-secret metadata. The
client secret remains in the CI job's memory and is never written to the
context, filesystem, OS secure store, or module environment.

## 6. Architecture-proof development credential

This context is not a production method. It exists only for the non-production
`wso2 reference status` architecture proof, and the shell implements it in the
reference namespace alone. It is included here because it follows the same
rule as every context above: the document names a credential source and never
holds a credential.

```json
{
"schemaVersion": 1,
"defaultContext": "reference-local",
"contexts": [
{
"name": "reference-local",
"organizationId": "reference-org",
"endpoint": "http://127.0.0.1:8080",
"auth": {
"method": "development-credential",
"credentialVariable": "WSO2_REFERENCE_DEV_CREDENTIAL"
}
}
]
}
```

The shell reads `WSO2_REFERENCE_DEV_CREDENTIAL` into memory, applies broker
policy, and exchanges it for a short-lived fixture token bound to the requested
audience and scope, the context's organization, and the current invocation. The
reference module receives that token and nothing else: not the credential, not
its source, and no way to renew what it was given.

## CI guidance

CI is non-interactive and must use:
Expand Down
14 changes: 13 additions & 1 deletion docs/plans/first-cli-vertical-slice.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,18 @@ The initial layout is:
├── internal/
│ ├── app/
│ ├── auth/
│ │ └── devtoken/
│ ├── boundaries/
│ ├── context/
│ ├── contexts/
│ │ └── fixture/
│ ├── exit/
│ ├── modules/
│ │ └── fixture/
│ ├── output/
│ ├── rpc/
│ ├── semver/
│ ├── state/
│ ├── statusservice/
│ └── version/
├── sdk/
│ ├── go.mod
Expand All @@ -124,6 +127,15 @@ installer of section 5, `internal/semver` implements receipt compatibility
ranges, `internal/state` locates the shell-owned state root, and
`test/acceptance` holds the black-box runs of increment 5.

`internal/auth` owns broker policy and `internal/auth/devtoken` the development
issuer of section 7. `internal/contexts` reads the contexts of section 7, and
its `fixture` package is the only writer of one, so no shell command can write
a context that grants itself access. `internal/statusservice` is the local
read-only service the proof calls; like the issuer, it is test infrastructure
and is never linked into the shell binary. The package is named `contexts`
rather than `context` so no file that reads a context has to rename the
standard library's `context`.

Dependency rules:

- the SDK imports no shell `internal` package;
Expand Down
69 changes: 42 additions & 27 deletions examples/reference-module/cmd/wso2-module-reference/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import (
"flag"
"fmt"
"os"
"time"

"github.com/wso2/wso2-cli/sdk/module"
"github.com/wso2/wso2-cli/sdk/result"
Expand All @@ -50,13 +49,6 @@ const (
// The shell renders it without interpreting it.
const StatusSchema = "reference.status/v1"

// staticOrganization is reported when the shell supplies no organization.
//
// The shell does not own a context store yet, so this slice increment answers
// with static semantic data. The authentication broker increment replaces it
// with the selected context's organization and a real status lookup.
const staticOrganization = "reference-org"

// moduleVersion is this module's own release version. A build injects it with:
//
// go build -ldflags "-X main.moduleVersion=0.1.0"
Expand All @@ -73,12 +65,7 @@ func main() {
os.Exit(2)
}

options := module.Options{
Namespace: Namespace,
Version: moduleVersion,
AuthAudiences: []string{StatusAudience},
AuthScopes: []string{StatusScope},
}
options := moduleOptions()

if *describe {
reportIdentity(module.Describe(options))
Expand All @@ -88,29 +75,57 @@ func main() {
// Standard output now carries protocol frames only. Anything this process
// wants to say goes to standard error, where the shell captures it as
// bounded diagnostics.
err := module.Serve(context.Background(), options,
module.Command{Path: []string{"status"}, Run: status})
err := module.Serve(context.Background(), options, statusCommand())
if err != nil {
fmt.Fprintf(os.Stderr, "wso2-module-reference: %v\n", err)
os.Exit(1)
}
}

// moduleOptions describe this module to the SDK.
func moduleOptions() module.Options {
return module.Options{
Namespace: Namespace,
Version: moduleVersion,
AuthAudiences: []string{StatusAudience},
AuthScopes: []string{StatusScope},
}
}

// statusCommand binds "wso2 reference status" to its handler.
func statusCommand() module.Command {
return module.Command{Path: []string{"status"}, Run: status}
}

// status answers "wso2 reference status".
//
// It returns semantic fields in presentation order and no formatting: the shell
// alone decides whether the user sees a table or JSON. The field order here is
// the order both renderings follow.
func status(_ context.Context, request module.Request) (result.Result, error) {
organization := request.Context.OrganizationID
if organization == "" {
organization = staticOrganization
// It asks the shell for access, reads the status service with what it was
// granted, and returns semantic fields in presentation order. It performs no
// formatting: the shell alone decides whether the user sees a table or JSON,
// and the field order here is the order both renderings follow.
//
// It never sees a credential. It asks for an audience and scope, receives a
// short-lived token, and has no way to obtain another.
func status(ctx context.Context, request module.Request) (result.Result, error) {
access, err := request.Access.Acquire(ctx, module.AccessRequest{
Audience: StatusAudience,
Scopes: []string{StatusScope},
})
if err != nil {
// A denial is the shell's own typed problem. Returning it unchanged
// keeps one account of why access was refused.
return result.Result{}, err
}

status, err := readStatus(ctx, request.Context.Endpoint, request.InvocationID, access.Token)
if err != nil {
return result.Result{}, err
}
return result.New(StatusSchema).
With("organization", "Organization", organization).
With("service", "Service", "reference").
With("status", "Status", "operational").
With("checkedAt", "Checked at", time.Now().UTC().Format(time.RFC3339)), nil
With("organization", "Organization", status.Organization).
With("service", "Service", status.Service).
With("status", "Status", status.Status).
With("checkedAt", "Checked at", status.CheckedAt), nil
}

// reportIdentity writes the module's runtime identity for tests.
Expand Down
135 changes: 135 additions & 0 deletions examples/reference-module/cmd/wso2-module-reference/status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
//
// WSO2 LLC. licenses this file to you under the Apache License,
// Version 2.0 (the "License"); you may not use this file except
// in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.

package main

import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/wso2/wso2-cli/sdk/problem"
)

// The call shape of the local status service.
//
// A product module cannot import a shell package, so what a service expects is
// knowledge the module carries on its own. These values are repeated in
// internal/statusservice, and the duplication is the boundary being real: the
// module and the service agree by contract, not by sharing code.
const (
statusPath = "/status"
// invocationHeader names the invocation this call belongs to. The service
// compares it with the token's own invocation claim, so access granted for
// one command cannot be presented under another.
invocationHeader = "X-WSO2-Invocation-Id"
)

// statusTimeout bounds one call to the status service. It is well inside the
// shell's invocation deadline, so a slow service produces this module's typed
// problem rather than the shell terminating the process.
const statusTimeout = 5 * time.Second

// serviceStatus is the status service's answer.
type serviceStatus struct {
Organization string `json:"organization"`
Service string `json:"service"`
Status string `json:"status"`
CheckedAt string `json:"checkedAt"`
}

// readStatus reads the status service with brokered access.
//
// Everything that can go wrong becomes a typed problem the shell can render and
// map to an exit class. None of them repeats the token: a problem is rendered
// verbatim, and access material has no place in user-facing text.
func readStatus(ctx context.Context, endpoint, invocationID, token string) (serviceStatus, error) {
if endpoint == "" {
return serviceStatus{}, problem.New(problem.CategoryUsage, "reference.no_endpoint",
"the selected context does not name a reference status service").
WithRecovery("Select a context whose endpoint names the local reference status service.")
}
target, err := url.JoinPath(endpoint, statusPath)
if err != nil {
return serviceStatus{}, problem.New(problem.CategoryUsage, "reference.unreadable_endpoint",
"the selected context names an endpoint this module cannot call").
WithRecovery("Select a context whose endpoint is an absolute HTTP URL.")
}

call, cancel := context.WithTimeout(ctx, statusTimeout)
defer cancel()
request, err := http.NewRequestWithContext(call, http.MethodGet, target, nil)
if err != nil {
return serviceStatus{}, unavailable("the reference status service cannot be called")
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set(invocationHeader, invocationID)
request.Header.Set("Accept", "application/json")

response, err := http.DefaultClient.Do(request)
if err != nil {
return serviceStatus{}, unavailable("the reference status service did not answer")
}
defer func() { _ = response.Body.Close() }()

if failure := statusFailure(response.StatusCode); failure != nil {
return serviceStatus{}, failure
}

// The body is bounded: a service that answered with a stream rather than a
// status document must not be able to exhaust this process.
body, err := io.ReadAll(io.LimitReader(response.Body, 64<<10))
if err != nil {
return serviceStatus{}, unavailable("the reference status service stopped part-way through its answer")
}
var status serviceStatus
if err := json.Unmarshal(body, &status); err != nil {
return serviceStatus{}, unavailable("the reference status service answered with something this module cannot read")
}
if strings.TrimSpace(status.Status) == "" {
return serviceStatus{}, unavailable("the reference status service reported no status")
}
return status, nil
}

// statusFailure maps a refused or failed answer onto this module's problems.
//
// A service that would not take the access it was given is reported apart from
// a service that failed, and both are product-service failures: the shell
// granted the access, so neither is a broker denial. Keeping them apart from
// each other, and from shell policy, is what lets automation tell "you may not"
// from "it is broken".
func statusFailure(status int) error {
switch {
case status == http.StatusOK:
return nil
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return problem.New(problem.CategoryProductService, "reference.status_access_rejected",
"the reference status service did not accept the access this command was granted").
WithRecovery("Retry the command. Report the failure if the service keeps refusing valid access.")
default:
return unavailable("the reference status service could not report its status")
}
}

func unavailable(message string) problem.Problem {
return problem.New(problem.CategoryProductService, "reference.status_unavailable", message).
WithRecovery("Retry the command. Report the failure if it persists.")
}
Loading