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
12 changes: 12 additions & 0 deletions apps/customer-portal/backend-v2/.choreo/component.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
schemaVersion: 1.2

endpoints:
- name: customer-portal-api
displayName: Customer Portal API
service:
basePath: /
port: 8080
type: REST
networkVisibilities:
- Public
schemaFilePath: openapi.yaml
20 changes: 16 additions & 4 deletions apps/customer-portal/backend-v2/.env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
# Entity service (cs-tools/entity-service)
ENTITY_SERVICE_BASE_URL=http://localhost:8081
# Optional — only needed if entity-service sits behind a gateway that requires
# OAuth2 client-credentials auth. Leave unset for a local direct-to-service run.
# Shared OAuth2 client credentials — entity-service, the updates service, and
# SCIM all authenticate as this same app; only each service's base URL and
# scopes differ. Optional — only needed if a service sits behind a gateway
# that requires OAuth2 client-credentials auth. Leave unset for a local
# direct-to-service run.
OAUTH2_CLIENT_ID=
OAUTH2_CLIENT_SECRET=
OAUTH2_TOKEN_URL=

# Entity service (cs-tools/entity-service)
ENTITY_SERVICE_BASE_URL=http://localhost:8081
ENTITY_SERVICE_SCOPES=

# WSO2 Updates service (product update levels / update descriptions)
UPDATES_BASE_URL=
UPDATES_SCOPES=

# SCIM operations service (phone number read/update for GET/PATCH /users/me)
SCIM_BASE_URL=
SCIM_SCOPES=

# Auth
# AUTH_TOKEN_VALIDATOR_ENABLED=false (below) skips JWT signature verification —
# local development only. Production MUST set it to true with a real
Expand Down
52 changes: 46 additions & 6 deletions apps/customer-portal/backend-v2/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ responses for the frontend. This is a Go rewrite of the Ballerina backend at
`apps/customer-portal/backend`, modeled on `apps/csm-portal/backend`'s conventions — read that
backend's own CLAUDE.md too if something here is underspecified.

**Status: in progress.** Only 6 routes are wired up so far (`GET /health`, `GET /users/me`,
`POST /projects/search`, `GET /projects/{id}`, `POST /cases/search`, `GET /cases/{id}`). The
Ballerina backend exposes ~100 routes across many more modules (accounts, deployments, deployed
products, attachments, comments, conversations, change requests, call requests, catalogs, time
cards, updates, registry tokens, contacts, escalations, product vulnerabilities, AI chat/websocket,
**Status: in progress.** 11 routes are wired up so far (`GET /health`, `GET`/`PATCH /users/me`,
`POST /accounts/search`, `GET /accounts/{id}`, `POST /projects/search`, `GET /projects/{id}`,
`POST /cases/search`, `GET /cases/{id}`, `GET /updates/product-update-levels`,
`POST /updates/levels/search`) across three upstream services: entity-service, the WSO2 Updates
service, and SCIM. The Ballerina backend exposes ~100 routes across many more modules (deployments,
deployed products, attachments, comments, conversations, change requests, call requests, catalogs,
time cards, registry tokens, contacts, escalations, product vulnerabilities, AI chat/websocket,
etc.) — none of those are ported yet. Follow the recipe below to add the next one.

## Which entity-service
Expand All @@ -27,6 +29,27 @@ equivalent at all (e.g. `GET /metadata` — entity-service has no metadata endpo
one; add a code comment at the call site noting the gap and flag it instead of fabricating a
response.

## Other upstream services

Not everything comes from entity-service. Two more upstream service clients exist, each following
the same `Config{BaseURL, TokenURL, ClientID, ClientSecret, Scopes}` + `Client` + `NewClient` +
private `do()` shape as `internal/entity`:

- **`internal/updates`** — the WSO2 Updates service (product update levels, update descriptions
between levels). Its own types are already portal-shaped camelCase (`internal/updates/types.go`
defines both the upstream snake_case wire structs and the portal camelCase structs, translated by
`internal/updates/mapper.go`) — so handlers write its return values directly via `writeJSONValue`
with **no** further `internal/dto` mapping layer. This is the one deliberate exception to the
"always map through dto" rule below, because the mapping already happened inside the client.
- **`internal/scim`** — the SCIM operations service, used only for a user's phone number
(`SearchUser`, `UpdateUserPhone`). Its `UserInfo` return type is already a small portal-clean
struct, so it's merged directly into `dto.UserMeResponse`/`dto.UserUpdateResponse` in
`internal/handler/users.go` — again no separate mapping layer needed.

All three service clients (entity, updates, SCIM) authenticate as the same shared OAuth2
client-credentials app in `cmd/server/main.go` — only each service's `*_BASE_URL`/`*_SCOPES` env
vars differ.

## Middleware chain

`SecurityHeaders → CorrelationID → Auth → Logger → Mux`
Expand Down Expand Up @@ -62,6 +85,20 @@ When you add a new endpoint, add the equivalent trimming — read the field care
including it; when in doubt whether a field is customer-appropriate, leave it out and note why in
a comment on the DTO struct (see `internal/dto/case.go` for examples).

**Data-source normalization is a second job of the DTO layer.** Unlike projects and cases,
entity-service's account endpoints (`GET /accounts/{id}`, `POST /accounts/search`) return a
genuinely different wire shape depending on whether it's deployed with `DATA_SOURCE=postgres` or
`DATA_SOURCE=servicenow` — see `internal/entity/types.go`'s `AccountDetail`/`AccountSummary`
comments for how the two shapes are unioned into one Go struct (their JSON keys never collide) and
`internal/dto/account.go` for how the DTO mapper picks whichever fields the active data source
populated (e.g. `Tier` prefers entity-service's `tier`, falling back to `classification`) to
produce one consistent contract for the frontend regardless of which data source is live. This is
a genuine advantage of the DTO-mapping convention over raw passthrough — `apps/csm-portal/backend`
has to model this as an OpenAPI `oneOf` in its spec and pass the ambiguity on to the frontend;
here, it's resolved once in the mapper. Also deliberately excluded from account DTOs: `ArrToday`
(annual recurring revenue — WSO2-internal financial data, never expose to the customer) and `Pod`
(WSO2-internal account routing).

Request bodies are the exception: incoming search/filter payloads are decoded directly into the
entity package's request structs (e.g. `entity.SearchProjectsRequest`) with no separate DTO layer,
since those shapes are already what the frontend needs to send — there is nothing to hide on the
Expand All @@ -85,7 +122,10 @@ request side.
6. **Route** (`cmd/server/main.go`) — register using Go 1.22 method-prefixed patterns:
`"POST /cases/{id}/comments"`.
7. **README** — add the endpoint under "API Endpoints" in `README.md`.
8. **gosec** — run `gosec -fmt=text ./...` (must report 0 issues) before opening a PR.
8. **OpenAPI spec** (`openapi.yaml`) — add the path with `200`/`400`/`401`/`403`/`500` responses
(`404` too for get-by-id, `413` too for endpoints with a request body); every endpoint must
declare `403` since `mapUpstreamError` can return it.
9. **gosec** — run `gosec -fmt=text ./...` (must report 0 issues) before opening a PR.

## Handler conventions

Expand Down
94 changes: 80 additions & 14 deletions apps/customer-portal/backend-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ Go rewrite of the Ballerina backend at `apps/customer-portal/backend`. It is a b
[`entity-service`](../../../entity-service) (this repo's `cs-tools/entity-service`, not the
`digiops-cs/entity-service` the Ballerina backend targets), and shapes the responses for the frontend.

This is a work in progress — only the first 6 routes are implemented so far (see below).
Everything else the Ballerina backend exposes still needs a Go handler; add them following the
pattern described in [CLAUDE.md](./CLAUDE.md#adding-a-new-endpoint).
This is a work in progress — only the 11 routes listed below are implemented so far, across
entity-service, the WSO2 Updates service, and SCIM. Everything else the Ballerina backend exposes
still needs a Go handler; add them following the pattern described in
[CLAUDE.md](./CLAUDE.md#adding-a-new-endpoint).

## Quick Start

Expand All @@ -27,14 +28,17 @@ Backend starts at `http://localhost:8080`.
- Entry point: `cmd/server/main.go`
- Authentication:
- Incoming requests: JWT Bearer token; pass as `x-jwt-assertion` header when testing locally
- Outbound calls to entity-service: OAuth2 client credentials grant (optional — entity-service
itself does not validate inbound credentials, see [CLAUDE.md](./CLAUDE.md))
- Outbound calls to entity-service, the Updates service, and SCIM: OAuth2 client credentials
grant, shared across all three (optional — entity-service itself does not validate inbound
credentials, see [CLAUDE.md](./CLAUDE.md))

## Prerequisites

- Go `1.26+` — [install](https://go.dev/doc/install)
- A running instance of `cs-tools/entity-service` (see its own README for `DATA_SOURCE` setup —
`GET /users/me` requires `DATA_SOURCE=servicenow`)
`GET`/`PATCH /users/me` require `DATA_SOURCE=servicenow`)
- A running instance of the WSO2 Updates service and the SCIM operations service (for the
`/updates/*` routes and phone-number fields on `/users/me`)

## Testing

Expand Down Expand Up @@ -71,14 +75,36 @@ The scan must report **0 issues** before opening a PR touching this backend.

Copy `.env.example` to `.env` and fill in the values.

### Shared OAuth2 client credentials

Every upstream service client (entity-service, updates, SCIM) authenticates as the same OAuth2
client-credentials app — only each service's base URL and scopes differ.

| Variable | Description |
|---|---|
| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` / `OAUTH2_TOKEN_URL` | Optional — only needed if a service sits behind a gateway requiring OAuth2 client-credentials auth |

### Entity service

| Variable | Description |
|---|---|
| `ENTITY_SERVICE_BASE_URL` | Base URL of `cs-tools/entity-service` |
| `OAUTH2_CLIENT_ID` / `OAUTH2_CLIENT_SECRET` / `OAUTH2_TOKEN_URL` | Optional — only needed if entity-service sits behind a gateway requiring OAuth2 client-credentials auth |
| `ENTITY_SERVICE_SCOPES` | Comma-separated OAuth2 scopes (optional) |

### Updates service

| Variable | Description |
|---|---|
| `UPDATES_BASE_URL` | Base URL of the WSO2 Updates service |
| `UPDATES_SCOPES` | Comma-separated OAuth2 scopes (optional) |

### SCIM operations service

| Variable | Description |
|---|---|
| `SCIM_BASE_URL` | Base URL of the SCIM operations service |
| `SCIM_SCOPES` | Comma-separated OAuth2 scopes (optional) |

### Auth

| Variable | Description |
Expand All @@ -102,13 +128,24 @@ backend-v2/
├── internal/
│ ├── apierror/ # Typed upstream error type (4xx/5xx passthrough)
│ ├── entity/ # OAuth2 HTTP client for cs-tools/entity-service
│ │ ├── client.go # Config/Client/do()/getJSON()/postJSON()
│ │ ├── client.go # Config/Client/do()/getJSON()/postJSON()/patchJSON()
│ │ ├── types.go # entity-service's wire-format structs (internal to this package)
│ │ ├── users.go # GetMe
│ │ ├── users.go # GetMe, PatchMe
│ │ ├── accounts.go # SearchAccounts, GetAccount
│ │ ├── projects.go # SearchProjects, GetProject
│ │ └── cases.go # SearchCases, GetCase
│ ├── updates/ # OAuth2 HTTP client for the WSO2 Updates service
│ │ ├── client.go # Config/Client/do()
│ │ ├── types.go # upstream (snake_case) vs portal (camelCase) structs
│ │ ├── mapper.go # snake_case <-> camelCase mapping
│ │ └── updates.go # GetProductUpdateLevels, SearchUpdatesBetweenUpdateLevels
│ ├── scim/ # OAuth2 HTTP client for the SCIM operations service
│ │ ├── client.go # Config/Client/do()
│ │ ├── types.go
│ │ └── scim.go # SearchUser, UpdateUserPhone
│ ├── dto/ # Portal-facing response shapes + Map* functions from entity types
│ │ ├── user.go
│ │ ├── account.go
│ │ ├── project.go
│ │ └── case.go
│ ├── middleware/
Expand All @@ -118,25 +155,38 @@ backend-v2/
│ │ └── security_headers.go # X-Content-Type-Options, CSP, HSTS on every response
│ └── handler/
│ ├── response.go # writeJSON/writeError/mapUpstreamError shared helpers
│ ├── users.go # GET /users/me
│ ├── users.go # GET/PATCH /users/me
│ ├── accounts.go # POST /accounts/search, GET /accounts/{id}
│ ├── projects.go # POST /projects/search, GET /projects/{id}
│ └── cases.go # POST /cases/search, GET /cases/{id}
│ ├── cases.go # POST /cases/search, GET /cases/{id}
│ └── updates.go # GET /updates/product-update-levels, POST /updates/levels/search
├── .choreo/component.yaml
├── openapi.yaml
├── .env.example
└── go.mod
```

## API Endpoints

- `GET /health` — liveness check, no auth
- `GET /users/me` — current user's profile (requires entity-service running with `DATA_SOURCE=servicenow`)
- `GET /users/me` — current user's profile; name/timezone/roles from entity-service (requires
`DATA_SOURCE=servicenow`), phone number from SCIM
- `PATCH /users/me` — update phone number (SCIM) and/or timezone (entity-service); at least one required
- `POST /accounts/search` — search accounts (normalizes entity-service's Postgres/ServiceNow shapes into one)
- `GET /accounts/{id}` — get account by ID (same normalization)
- `POST /projects/search` — search projects
- `GET /projects/{id}` — get project by ID
- `POST /cases/search` — search cases
- `GET /cases/{id}` — get case by ID
- `GET /updates/product-update-levels` — list product update levels
- `POST /updates/levels/search` — search update descriptions between two update levels

All response shapes are portal-owned DTOs (see `internal/dto`) — entity-service's raw response is
Full request/response schemas are documented in [openapi.yaml](./openapi.yaml). All entity-service
and SCIM response shapes are portal-owned DTOs (see `internal/dto`) — the raw upstream response is
never returned verbatim; see [CLAUDE.md](./CLAUDE.md#response-shaping) for what is deliberately
excluded from each and why.
excluded from each and why. The `updates` client is the one exception — its own types are already
portal-shaped camelCase (translated from the upstream snake_case in `internal/updates/mapper.go`),
so handlers write its return values directly with no further DTO layer.

## Run Locally

Expand All @@ -154,6 +204,16 @@ JWT="<your-jwt-token>"

curl -H "x-jwt-assertion: $JWT" http://localhost:8080/users/me

curl -X PATCH http://localhost:8080/users/me \
-H "x-jwt-assertion: $JWT" -H "Content-Type: application/json" \
-d '{"timeZone":"Asia/Colombo"}'

curl -X POST http://localhost:8080/accounts/search \
-H "x-jwt-assertion: $JWT" -H "Content-Type: application/json" \
-d '{"pagination":{"limit":10,"offset":0},"filters":{"searchQuery":"acme"}}'

curl -H "x-jwt-assertion: $JWT" http://localhost:8080/accounts/<account-id>

curl -X POST http://localhost:8080/projects/search \
-H "x-jwt-assertion: $JWT" -H "Content-Type: application/json" \
-d '{"pagination":{"limit":10,"offset":0},"searchQuery":"acme"}'
Expand All @@ -165,4 +225,10 @@ curl -X POST http://localhost:8080/cases/search \
-d '{"pagination":{"limit":10,"offset":0},"filters":{"searchQuery":"login error"}}'

curl -H "x-jwt-assertion: $JWT" http://localhost:8080/cases/<case-id>

curl -H "x-jwt-assertion: $JWT" http://localhost:8080/updates/product-update-levels

curl -X POST http://localhost:8080/updates/levels/search \
-H "x-jwt-assertion: $JWT" -H "Content-Type: application/json" \
-d '{"productName":"wso2am","productVersion":"4.2.0","startingUpdateLevel":1,"endingUpdateLevel":10}'
```
48 changes: 42 additions & 6 deletions apps/customer-portal/backend-v2/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,53 @@ import (
"github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/entity"
"github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/handler"
"github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/middleware"
"github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/scim"
"github.com/wso2-open-operations/cs-tools/apps/customer-portal/backend-v2/internal/updates"
)

func main() {
loadDotEnv(".env")
middleware.ConfigureLogger()

// entity-service, the updates service, and SCIM all authenticate as the
// same OAuth2 client-credentials app; only each service's base URL and
// scopes differ.
oauth2ClientID := os.Getenv("OAUTH2_CLIENT_ID")
oauth2ClientSecret := os.Getenv("OAUTH2_CLIENT_SECRET")
oauth2TokenURL := os.Getenv("OAUTH2_TOKEN_URL")

entityCfg := entity.Config{
BaseURL: mustEnv("ENTITY_SERVICE_BASE_URL"),
TokenURL: os.Getenv("OAUTH2_TOKEN_URL"),
ClientID: os.Getenv("OAUTH2_CLIENT_ID"),
ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"),
TokenURL: oauth2TokenURL,
ClientID: oauth2ClientID,
ClientSecret: oauth2ClientSecret,
Scopes: splitComma(os.Getenv("ENTITY_SERVICE_SCOPES")),
}
entityClient := entity.NewClient(entityCfg)

userHandler := handler.NewUserHandler(entityClient)
updatesCfg := updates.Config{
BaseURL: mustEnv("UPDATES_BASE_URL"),
TokenURL: oauth2TokenURL,
ClientID: oauth2ClientID,
ClientSecret: oauth2ClientSecret,
Scopes: splitComma(os.Getenv("UPDATES_SCOPES")),
}
updatesClient := updates.NewClient(updatesCfg)

scimCfg := scim.Config{
BaseURL: mustEnv("SCIM_BASE_URL"),
TokenURL: oauth2TokenURL,
ClientID: oauth2ClientID,
ClientSecret: oauth2ClientSecret,
Scopes: splitComma(os.Getenv("SCIM_SCOPES")),
}
scimClient := scim.NewClient(scimCfg)

userHandler := handler.NewUserHandler(entityClient, scimClient)
projectHandler := handler.NewProjectHandler(entityClient)
caseHandler := handler.NewCaseHandler(entityClient)
accountHandler := handler.NewAccountHandler(entityClient)
updatesHandler := handler.NewUpdatesHandler(updatesClient)

authCfg := middleware.Config{
JWKSEndpoint: mustEnv("AUTH_JWKS_ENDPOINT"),
Expand All @@ -66,16 +95,23 @@ func main() {
w.WriteHeader(http.StatusOK)
})

// GET /users/me is only served by entity-service when it runs with
// DATA_SOURCE=servicenow — see internal/entity/users.go.
// GET/PATCH /users/me are only served by entity-service when it runs
// with DATA_SOURCE=servicenow — see internal/entity/users.go.
mux.HandleFunc("GET /users/me", userHandler.GetMe)
mux.HandleFunc("PATCH /users/me", userHandler.PatchMe)

mux.HandleFunc("POST /projects/search", projectHandler.SearchProjects)
mux.HandleFunc("GET /projects/{id}", projectHandler.GetProject)

mux.HandleFunc("POST /cases/search", caseHandler.SearchCases)
mux.HandleFunc("GET /cases/{id}", caseHandler.GetCase)

mux.HandleFunc("POST /accounts/search", accountHandler.SearchAccounts)
mux.HandleFunc("GET /accounts/{id}", accountHandler.GetAccount)

mux.HandleFunc("GET /updates/product-update-levels", updatesHandler.GetProductUpdateLevels)
mux.HandleFunc("POST /updates/levels/search", updatesHandler.SearchUpdatesBetweenUpdateLevels)

addr := ":" + mustPort("PORT", "8080")

ln, err := net.Listen("tcp", addr)
Expand Down
Loading