Skip to content

Commit 133f99b

Browse files
Move signer UI into extension-owned Next.js zone
1 parent 6c6514b commit 133f99b

97 files changed

Lines changed: 9204 additions & 560 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
name: Signer Web Verify
19+
20+
on:
21+
pull_request:
22+
paths:
23+
- 'extensions/SSH-Certificate-Signer/web/**'
24+
- 'web/**'
25+
- '.github/workflows/signer-web-verify.yml'
26+
push:
27+
branches: [master]
28+
paths:
29+
- 'extensions/SSH-Certificate-Signer/web/**'
30+
- 'web/**'
31+
- '.github/workflows/signer-web-verify.yml'
32+
workflow_dispatch:
33+
34+
jobs:
35+
verify:
36+
name: Signer zone and multi-zone integration
37+
runs-on: ubuntu-latest
38+
timeout-minutes: 20
39+
env:
40+
NEXTAUTH_SECRET: ci-only-dummy-secret
41+
CUSTOS_SIGNER_API_BASE_URL: http://localhost:8084
42+
CUSTOS_PORTAL_BASE_URL: http://localhost:3000
43+
steps:
44+
- name: Checkout
45+
uses: actions/checkout@v4
46+
47+
- name: Set up pnpm
48+
uses: pnpm/action-setup@v4
49+
with:
50+
package_json_file: extensions/SSH-Certificate-Signer/web/package.json
51+
52+
- name: Set up Node
53+
uses: actions/setup-node@v4
54+
with:
55+
node-version: 22
56+
cache: pnpm
57+
cache-dependency-path: |
58+
extensions/SSH-Certificate-Signer/web/pnpm-lock.yaml
59+
web/pnpm-lock.yaml
60+
61+
- name: Install signer zone
62+
working-directory: extensions/SSH-Certificate-Signer/web
63+
run: pnpm install --frozen-lockfile
64+
65+
- name: Install portal
66+
working-directory: web
67+
run: pnpm install --frozen-lockfile
68+
69+
- name: Verify signer zone
70+
working-directory: extensions/SSH-Certificate-Signer/web
71+
run: pnpm verify
72+
73+
- name: Install Chromium
74+
working-directory: extensions/SSH-Certificate-Signer/web
75+
run: pnpm exec playwright install --with-deps chromium
76+
77+
- name: Run multi-zone end-to-end tests
78+
working-directory: extensions/SSH-Certificate-Signer/web
79+
run: pnpm test:e2e
80+
81+
- name: Upload Playwright artifacts
82+
if: failure()
83+
uses: actions/upload-artifact@v4
84+
with:
85+
name: signer-zone-playwright-artifacts
86+
path: |
87+
extensions/SSH-Certificate-Signer/web/playwright-report/
88+
extensions/SSH-Certificate-Signer/web/test-results/
89+
if-no-files-found: ignore
90+
retention-days: 7

.github/workflows/web-verify.yml

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -75,46 +75,3 @@ jobs:
7575

7676
- name: Production build
7777
run: pnpm build
78-
79-
signer-e2e:
80-
name: Signer Playwright
81-
runs-on: ubuntu-latest
82-
timeout-minutes: 15
83-
defaults:
84-
run:
85-
working-directory: web
86-
steps:
87-
- name: Checkout
88-
uses: actions/checkout@v4
89-
90-
- name: Set up pnpm
91-
uses: pnpm/action-setup@v4
92-
with:
93-
package_json_file: web/package.json
94-
95-
- name: Set up Node
96-
uses: actions/setup-node@v4
97-
with:
98-
node-version: 22
99-
cache: pnpm
100-
cache-dependency-path: web/pnpm-lock.yaml
101-
102-
- name: Install
103-
run: pnpm install --frozen-lockfile
104-
105-
- name: Install Chromium
106-
run: pnpm exec playwright install --with-deps chromium
107-
108-
- name: Run signer end-to-end tests
109-
run: pnpm test:e2e:signer
110-
111-
- name: Upload Playwright artifacts
112-
if: failure()
113-
uses: actions/upload-artifact@v4
114-
with:
115-
name: signer-playwright-artifacts
116-
path: |
117-
web/playwright-report/
118-
web/test-results/
119-
if-no-files-found: ignore
120-
retention-days: 7

extensions/SSH-Certificate-Signer/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Part of [Apache Airavata Custos](https://airavata.apache.org/custos/), a securit
3030
- Go 1.22+
3131
- MariaDB 10.6+
3232
- Vault / OpenBao with KV v2 secrets engine enabled
33+
- Node.js 22+ and pnpm 9+ for the signer-owned web zone
3334

3435
---
3536

@@ -78,6 +79,7 @@ Environment variables take precedence over YAML values.
7879
| `DEV_DEFAULT_EMAIL` | `dev_mode.default_email` | Default email in dev mode |
7980
| `ALLOWED_ISSUERS` | `signer.auth.allowed_issuers` | Comma-separated list of allowed OIDC issuers |
8081
| `LOG_LEVEL` | `logging.level` | Log level: debug, info, warn, error |
82+
| `SIGNER_WEB_BASE_URL` | `web.base_url` | Origin serving the signer web zone (default `http://localhost:3001`) |
8183

8284
---
8385

@@ -219,8 +221,17 @@ The service handles SIGTERM and SIGINT for graceful shutdown:
219221
| `GET` | `/api/v1/certificates` | OIDC Bearer | List certificates for authenticated user |
220222
| `GET` | `/api/v1/certificates/{serial}` | OIDC Bearer | Get certificate details |
221223
| `GET` | `/api/v1/userinfo` | OIDC Bearer | Get authenticated user profile |
224+
| `GET` | `/.well-known/custos-extension.json` | None | Discover the signer web zone and navigation |
222225
| `GET` | `/metrics` | None | Prometheus metrics |
223226

227+
## Web zone
228+
229+
The certificate administration UI is owned by this extension under
230+
[`web/`](./web/). It is a standalone Next.js application with base path
231+
`/signer`. The Custos portal discovers it through the public extension manifest
232+
and proxies `/signer/*` with Next.js multi-zone rewrites. See the web zone
233+
README for local two-process setup and verification commands.
234+
224235
Client credentials are passed via `X-Client-Id` (format: `{tenant_id}:{client_id}`) and `X-Client-Secret` headers. OIDC Bearer endpoints use `Authorization: Bearer <token>`.
225236

226237
### API Examples

extensions/SSH-Certificate-Signer/config.example.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ server:
2121
write_timeout_seconds: 30 # HTTP write timeout
2222
shutdown_timeout_seconds: 30 # Graceful shutdown drain
2323

24+
web:
25+
base_url: http://localhost:3001 # env: SIGNER_WEB_BASE_URL
26+
2427
database:
2528
host: localhost # env: DB_HOST
2629
port: 3306 # env: DB_PORT

extensions/SSH-Certificate-Signer/internal/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626

2727
type Config struct {
2828
Server ServerConfig `yaml:"server"`
29+
Web WebConfig `yaml:"web"`
2930
Database DatabaseConfig `yaml:"database"`
3031
Vault VaultConfig `yaml:"vault"`
3132
Signer SignerConfig `yaml:"signer"`
@@ -35,6 +36,10 @@ type Config struct {
3536
CORS CORSConfig `yaml:"cors"`
3637
}
3738

39+
type WebConfig struct {
40+
BaseURL string `yaml:"base_url"`
41+
}
42+
3843
// DevModeConfig disables OIDC token validation and returns a default identity
3944
// for all requests when enabled.
4045
type DevModeConfig struct {
@@ -133,6 +138,7 @@ func DefaultConfig() *Config {
133138
WriteTimeoutSeconds: 30,
134139
ShutdownTimeoutSeconds: 30,
135140
},
141+
Web: WebConfig{BaseURL: "http://localhost:3001"},
136142
Database: DatabaseConfig{
137143
Host: "localhost",
138144
Port: 3306,
@@ -206,6 +212,9 @@ func Load(path string) (*Config, error) {
206212
}
207213

208214
func applyEnvOverrides(cfg *Config) {
215+
if v := os.Getenv("SIGNER_WEB_BASE_URL"); v != "" {
216+
cfg.Web.BaseURL = v
217+
}
209218
if v := os.Getenv("DB_HOST"); v != "" {
210219
cfg.Database.Host = v
211220
}

extensions/SSH-Certificate-Signer/internal/config/config_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ func TestLoad_DefaultConfig(t *testing.T) {
2929
if cfg.Server.Port != 8084 {
3030
t.Errorf("expected port 8084, got %d", cfg.Server.Port)
3131
}
32+
if cfg.Web.BaseURL != "http://localhost:3001" {
33+
t.Errorf("expected default web URL, got %q", cfg.Web.BaseURL)
34+
}
3235
if cfg.Database.Host != "localhost" {
3336
t.Errorf("expected host localhost, got %s", cfg.Database.Host)
3437
}
@@ -86,6 +89,7 @@ database:
8689
t.Setenv("DEV_DEFAULT_EMAIL", "test@dev.local")
8790
t.Setenv("ALLOWED_ISSUERS", "https://a.com, https://b.com")
8891
t.Setenv("LOG_LEVEL", "debug")
92+
t.Setenv("SIGNER_WEB_BASE_URL", "https://signer.example.org")
8993

9094
cfg, err := Load(path)
9195
if err != nil {
@@ -112,6 +116,9 @@ database:
112116
if cfg.Logging.Level != "debug" {
113117
t.Errorf("expected debug log level, got %s", cfg.Logging.Level)
114118
}
119+
if cfg.Web.BaseURL != "https://signer.example.org" {
120+
t.Errorf("expected signer web URL override, got %q", cfg.Web.BaseURL)
121+
}
115122
}
116123

117124
func TestLoad_FileNotFound(t *testing.T) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package handler
19+
20+
import (
21+
"encoding/json"
22+
"net/http"
23+
24+
"github.com/apache/airavata-custos/signer/internal/config"
25+
)
26+
27+
type ExtensionManifestHandler struct {
28+
webURL string
29+
}
30+
31+
type extensionManifest struct {
32+
SchemaVersion int `json:"schema_version"`
33+
ID string `json:"id"`
34+
Name string `json:"name"`
35+
BasePath string `json:"base_path"`
36+
WebURL string `json:"web_url"`
37+
Navigation []extensionNavigationItem `json:"navigation"`
38+
}
39+
40+
type extensionNavigationItem struct {
41+
Href string `json:"href"`
42+
Label string `json:"label"`
43+
Group string `json:"group"`
44+
Icon string `json:"icon"`
45+
RequiredPrivilege string `json:"required_privilege"`
46+
}
47+
48+
func NewExtensionManifestHandler(cfg config.WebConfig) *ExtensionManifestHandler {
49+
return &ExtensionManifestHandler{webURL: cfg.BaseURL}
50+
}
51+
52+
func (h *ExtensionManifestHandler) Handle(w http.ResponseWriter, _ *http.Request) {
53+
w.Header().Set("Content-Type", "application/json")
54+
w.Header().Set("Cache-Control", "public, max-age=60")
55+
_ = json.NewEncoder(w).Encode(extensionManifest{
56+
SchemaVersion: 1,
57+
ID: "ssh-certificate-signer",
58+
Name: "SSH Certificate Signer",
59+
BasePath: "/signer",
60+
WebURL: h.webURL,
61+
Navigation: []extensionNavigationItem{{
62+
Href: "/signer/certificates",
63+
Label: "SSH Certificates",
64+
Group: "admin",
65+
Icon: "key-round",
66+
RequiredPrivilege: "signer:certificates:read",
67+
}},
68+
})
69+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package handler
19+
20+
import (
21+
"encoding/json"
22+
"net/http/httptest"
23+
"testing"
24+
25+
"github.com/apache/airavata-custos/signer/internal/config"
26+
)
27+
28+
func TestExtensionManifest(t *testing.T) {
29+
h := NewExtensionManifestHandler(config.WebConfig{BaseURL: "https://signer.example.org"})
30+
recorder := httptest.NewRecorder()
31+
h.Handle(recorder, httptest.NewRequest("GET", "/.well-known/custos-extension.json", nil))
32+
33+
if recorder.Code != 200 {
34+
t.Fatalf("status = %d, want 200", recorder.Code)
35+
}
36+
if got := recorder.Header().Get("Content-Type"); got != "application/json" {
37+
t.Fatalf("content type = %q", got)
38+
}
39+
var manifest extensionManifest
40+
if err := json.NewDecoder(recorder.Body).Decode(&manifest); err != nil {
41+
t.Fatal(err)
42+
}
43+
if manifest.SchemaVersion != 1 || manifest.BasePath != "/signer" {
44+
t.Fatalf("unexpected manifest: %+v", manifest)
45+
}
46+
if manifest.WebURL != "https://signer.example.org" {
47+
t.Fatalf("web_url = %q", manifest.WebURL)
48+
}
49+
if len(manifest.Navigation) != 1 || manifest.Navigation[0].RequiredPrivilege != "signer:certificates:read" {
50+
t.Fatalf("unexpected navigation: %+v", manifest.Navigation)
51+
}
52+
}

extensions/SSH-Certificate-Signer/internal/server/routes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
)
2727

2828
type Handlers struct {
29+
ExtensionManifest http.HandlerFunc
2930
Sign http.HandlerFunc
3031
Revoke http.HandlerFunc
3132
JWKS http.HandlerFunc
@@ -52,6 +53,9 @@ func NewRouter(
5253
r.Use(SourceIPMiddleware)
5354
r.Use(BodyLimitMiddleware(1 << 20)) // 1 MB
5455

56+
// Extension discovery endpoint (no auth)
57+
r.Get("/.well-known/custos-extension.json", handlers.ExtensionManifest)
58+
5559
// Health endpoint (no auth)
5660
r.Get("/api/v1/health", handlers.Health)
5761

0 commit comments

Comments
 (0)