Skip to content

Commit 8c15724

Browse files
author
Onur
authored
feat(provisioning): add external provisioner service principal flow (#94)
* docs(provisioning): define external provisioner architecture * refactor(terminology): replace spritz kind fields with type * feat(provisioning): add service principal provisioner flow * fix(provisioning): address service principal review findings * fix(provisioning): address local review findings * fix(provisioning): tighten service principal policy * fix(provisioning): preserve preset and bearer defaults * fix(provisioning): honor default presets and retry pending ids * fix(provisioning): tighten auth and async prompt activity * fix(provisioning): tighten create contract semantics * fix(provisioning): redact preset secrets and replay pending creates * fix(provisioning): harden header auth and ssh activity * fix(provisioning): tighten idempotent replay ownership * fix(provisioning): scope reservations to control namespace * fix(provisioning): harden replay and preset discovery * fix(provisioning): align control namespace and lifecycle copies * fix(provisioning): preserve pending idempotent names * fix(provisioning): align default preset flows * feat(provisioning): enforce strict idempotency cutover * refactor(provisioning): extract create transaction flow * refactor(api): extract create normalization and reservation store
1 parent d90eaeb commit 8c15724

52 files changed

Lines changed: 6338 additions & 323 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The backend inside a workspace is pluggable. OpenClaw is one example runtime. An
1616

1717
> Spritz is in active development and should be treated as alpha software. APIs, CRDs, Helm values, and UI details may still change while the deployment model is being hardened.
1818
19-
[Deployment Spec](docs/2026-02-24-simplest-spritz-deployment-spec.md) · [ACP Architecture](docs/2026-03-09-acp-port-and-agent-chat-architecture.md) · [Portable Auth](docs/2026-02-24-portable-authentication-and-account-architecture.md) · [OpenClaw Integration](OPENCLAW.md)
19+
[Deployment Spec](docs/2026-02-24-simplest-spritz-deployment-spec.md) · [ACP Architecture](docs/2026-03-09-acp-port-and-agent-chat-architecture.md) · [Portable Auth](docs/2026-02-24-portable-authentication-and-account-architecture.md) · [External Provisioners](docs/2026-03-11-external-provisioner-and-service-principal-architecture.md) · [OpenClaw Integration](OPENCLAW.md)
2020

2121
## Vision
2222

@@ -182,4 +182,5 @@ Spritz is intended to remain portable and standalone:
182182
- `docs/2026-02-24-simplest-spritz-deployment-spec.md`
183183
- `docs/2026-02-24-portable-authentication-and-account-architecture.md`
184184
- `docs/2026-03-09-acp-port-and-agent-chat-architecture.md`
185+
- `docs/2026-03-11-external-provisioner-and-service-principal-architecture.md`
185186
- `OPENCLAW.md`

‎api/acp_agents.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@ func (s *server) listACPAgents(c echo.Context) error {
3232
if err := s.client.List(c.Request().Context(), list, opts...); err != nil {
3333
return writeError(c, http.StatusInternalServerError, err.Error())
3434
}
35+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
36+
return writeForbidden(c)
37+
}
3538

3639
records := make([]acpAgentResponse, 0, len(list.Items))
3740
for _, item := range list.Items {
38-
if s.auth.enabled() && !principal.IsAdmin && item.Spec.Owner.ID != principal.ID {
41+
if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, s.auth.enabled()); err != nil {
3942
continue
4043
}
4144
if !spritzSupportsACPConversations(&item) {

‎api/acp_bootstrap.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,9 @@ func (s *server) bootstrapACPConversation(c echo.Context) error {
296296
if s.auth.enabled() && (!ok || principal.ID == "") {
297297
return writeError(c, http.StatusUnauthorized, "unauthenticated")
298298
}
299+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
300+
return writeForbidden(c)
301+
}
299302
namespace := s.requestNamespace(c)
300303
if namespace == "" {
301304
namespace = "default"

‎api/acp_conversations.go‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ func (s *server) listACPConversations(c echo.Context) error {
3636

3737
namespace := s.requestNamespace(c)
3838
spritzName := strings.TrimSpace(c.QueryParam("spritz"))
39+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
40+
return writeForbidden(c)
41+
}
3942
list := &spritzv1.SpritzConversationList{}
4043
opts := []client.ListOption{}
4144
if namespace != "" {
@@ -45,7 +48,7 @@ func (s *server) listACPConversations(c echo.Context) error {
4548
if spritzName != "" {
4649
labels[acpConversationSpritzLabelKey] = spritzName
4750
}
48-
if s.auth.enabled() && !principal.IsAdmin {
51+
if s.auth.enabled() && !principal.isAdminPrincipal() {
4952
labels[acpConversationOwnerLabelKey] = ownerLabelValue(principal.ID)
5053
}
5154
opts = append(opts, client.MatchingLabels(labels))
@@ -55,7 +58,7 @@ func (s *server) listACPConversations(c echo.Context) error {
5558

5659
items := make([]spritzv1.SpritzConversation, 0, len(list.Items))
5760
for _, item := range list.Items {
58-
if s.auth.enabled() && !principal.IsAdmin && item.Spec.Owner.ID != principal.ID {
61+
if err := authorizeHumanOwnedAccess(principal, item.Spec.Owner.ID, s.auth.enabled()); err != nil {
5962
continue
6063
}
6164
if spritzName != "" && item.Spec.SpritzName != spritzName {
@@ -75,6 +78,9 @@ func (s *server) getACPConversation(c echo.Context) error {
7578
if s.auth.enabled() && (!ok || principal.ID == "") {
7679
return writeError(c, http.StatusUnauthorized, "unauthenticated")
7780
}
81+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
82+
return writeForbidden(c)
83+
}
7884
conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, s.requestNamespace(c), c.Param("id"))
7985
if err != nil {
8086
return s.writeACPConversationError(c, err)
@@ -90,6 +96,9 @@ func (s *server) createACPConversation(c echo.Context) error {
9096
if s.auth.enabled() && (!ok || principal.ID == "") {
9197
return writeError(c, http.StatusUnauthorized, "unauthenticated")
9298
}
99+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
100+
return writeForbidden(c)
101+
}
93102

94103
var body createACPConversationRequest
95104
if err := decodeACPBody(c, &body); err != nil {
@@ -135,6 +144,9 @@ func (s *server) updateACPConversation(c echo.Context) error {
135144
if s.auth.enabled() && (!ok || principal.ID == "") {
136145
return writeError(c, http.StatusUnauthorized, "unauthenticated")
137146
}
147+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
148+
return writeForbidden(c)
149+
}
138150
conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, s.requestNamespace(c), c.Param("id"))
139151
if err != nil {
140152
return s.writeACPConversationError(c, err)
@@ -189,7 +201,7 @@ func (s *server) getAuthorizedConversation(ctx context.Context, principal princi
189201
if err := s.client.Get(ctx, clientKey(namespace, name), conversation); err != nil {
190202
return nil, err
191203
}
192-
if s.auth.enabled() && !principal.IsAdmin && conversation.Spec.Owner.ID != principal.ID {
204+
if err := authorizeHumanOwnedAccess(principal, conversation.Spec.Owner.ID, s.auth.enabled()); err != nil {
193205
return nil, errForbidden
194206
}
195207
return conversation, nil

‎api/acp_gateway.go‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package main
22

33
import (
4+
"encoding/json"
45
"net/http"
6+
"strings"
57
"sync"
68

79
"github.com/gorilla/websocket"
@@ -16,6 +18,9 @@ func (s *server) openACPConversationConnection(c echo.Context) error {
1618
if s.auth.enabled() && (!ok || principal.ID == "") {
1719
return writeError(c, http.StatusUnauthorized, "unauthenticated")
1820
}
21+
if err := authorizeHumanOnly(principal, s.auth.enabled()); err != nil {
22+
return writeForbidden(c)
23+
}
1924
namespace := s.requestNamespace(c)
2025
conversation, err := s.getAuthorizedConversation(c.Request().Context(), principal, namespace, c.Param("id"))
2126
if err != nil {
@@ -47,19 +52,26 @@ func (s *server) openACPConversationConnection(c echo.Context) error {
4752
_ = workspaceConn.Close()
4853
}()
4954

50-
return proxyWebSockets(browserConn, workspaceConn)
55+
return proxyWebSockets(
56+
browserConn,
57+
workspaceConn,
58+
func(payload []byte) {
59+
s.scheduleACPPromptActivity(c.Logger(), spritz.Namespace, spritz.Name, payload)
60+
},
61+
nil,
62+
)
5163
}
5264

53-
func proxyWebSockets(left, right *websocket.Conn) error {
65+
func proxyWebSockets(left, right *websocket.Conn, onLeftMessage, onRightMessage func([]byte)) error {
5466
errCh := make(chan error, 2)
5567
closeOnce := sync.Once{}
5668
closeBoth := func() {
5769
_ = left.Close()
5870
_ = right.Close()
5971
}
6072

61-
go proxyWebSocketDirection(left, right, errCh)
62-
go proxyWebSocketDirection(right, left, errCh)
73+
go proxyWebSocketDirection(left, right, errCh, onLeftMessage)
74+
go proxyWebSocketDirection(right, left, errCh, onRightMessage)
6375

6476
err := <-errCh
6577
closeOnce.Do(closeBoth)
@@ -69,16 +81,29 @@ func proxyWebSockets(left, right *websocket.Conn) error {
6981
return err
7082
}
7183

72-
func proxyWebSocketDirection(src, dst *websocket.Conn, errCh chan<- error) {
84+
func proxyWebSocketDirection(src, dst *websocket.Conn, errCh chan<- error, onMessage func([]byte)) {
7385
for {
7486
msgType, payload, err := src.ReadMessage()
7587
if err != nil {
7688
errCh <- err
7789
return
7890
}
91+
if onMessage != nil {
92+
onMessage(payload)
93+
}
7994
if err := dst.WriteMessage(msgType, payload); err != nil {
8095
errCh <- err
8196
return
8297
}
8398
}
8499
}
100+
101+
func isACPPromptMessage(payload []byte) bool {
102+
var message struct {
103+
Method string `json:"method"`
104+
}
105+
if err := json.Unmarshal(payload, &message); err != nil {
106+
return false
107+
}
108+
return strings.TrimSpace(message.Method) == "session/prompt"
109+
}

‎api/acp_gateway_test.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
"testing"
7+
"time"
8+
)
9+
10+
type noopWarnLogger struct{}
11+
12+
func (noopWarnLogger) Warnf(string, ...interface{}) {}
13+
14+
func TestScheduleACPPromptActivityDoesNotBlockPromptPath(t *testing.T) {
15+
started := make(chan struct{}, 1)
16+
release := make(chan struct{})
17+
var calls atomic.Int32
18+
19+
s := &server{
20+
activityRecorder: func(ctx context.Context, namespace, name string, when time.Time) error {
21+
calls.Add(1)
22+
select {
23+
case started <- struct{}{}:
24+
default:
25+
}
26+
select {
27+
case <-release:
28+
return nil
29+
case <-ctx.Done():
30+
return ctx.Err()
31+
}
32+
},
33+
}
34+
35+
start := time.Now()
36+
s.scheduleACPPromptActivity(noopWarnLogger{}, "spritz-test", "young-crest", []byte(`{"jsonrpc":"2.0","method":"session/prompt"}`))
37+
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
38+
t.Fatalf("expected prompt activity scheduling to return immediately, took %s", elapsed)
39+
}
40+
41+
select {
42+
case <-started:
43+
case <-time.After(250 * time.Millisecond):
44+
t.Fatalf("expected background activity recorder to start")
45+
}
46+
47+
close(release)
48+
49+
deadline := time.Now().Add(250 * time.Millisecond)
50+
for time.Now().Before(deadline) {
51+
if calls.Load() == 1 {
52+
return
53+
}
54+
time.Sleep(5 * time.Millisecond)
55+
}
56+
t.Fatalf("expected one background activity write, got %d", calls.Load())
57+
}

‎api/acp_helpers.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ func (s *server) getAuthorizedSpritz(ctx context.Context, principal principal, n
166166
}
167167
return nil, err
168168
}
169-
if s.auth.enabled() && !principal.IsAdmin && spritz.Spec.Owner.ID != principal.ID {
169+
if err := authorizeHumanOwnedAccess(principal, spritz.Spec.Owner.ID, s.auth.enabled()); err != nil {
170170
return nil, errForbidden
171171
}
172172
return spritz, nil

‎api/acp_test.go‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/gorilla/websocket"
1414
"github.com/labstack/echo/v4"
15+
corev1 "k8s.io/api/core/v1"
1516
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1617
"k8s.io/apimachinery/pkg/runtime"
1718
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -26,6 +27,9 @@ func newACPTestScheme(t *testing.T) *runtime.Scheme {
2627
if err := spritzv1.AddToScheme(scheme); err != nil {
2728
t.Fatalf("failed to register spritz scheme: %v", err)
2829
}
30+
if err := corev1.AddToScheme(scheme); err != nil {
31+
t.Fatalf("failed to register core scheme: %v", err)
32+
}
2933
return scheme
3034
}
3135

@@ -41,8 +45,9 @@ func newACPTestServer(t *testing.T, objects ...client.Object) *server {
4145
scheme: scheme,
4246
namespace: "spritz-test",
4347
auth: authConfig{
44-
mode: authModeHeader,
45-
headerID: "X-Spritz-User-Id",
48+
mode: authModeHeader,
49+
headerID: "X-Spritz-User-Id",
50+
headerDefaultType: principalTypeHuman,
4651
},
4752
internalAuth: internalAuthConfig{enabled: false},
4853
acp: acpConfig{
@@ -427,6 +432,41 @@ func TestListAndPatchACPConversationsByID(t *testing.T) {
427432
}
428433
}
429434

435+
func TestListACPConversationsAllowsAdminToSeeAllOwners(t *testing.T) {
436+
now := metav1.Now()
437+
spritz := readyACPSpritz("tidy-otter", "user-1")
438+
ownerOne := conversationFor("tidy-otter-user-1", "tidy-otter", "user-1", "Owner one", now)
439+
ownerTwo := conversationFor("tidy-otter-user-2", "tidy-otter", "user-2", "Owner two", now)
440+
441+
s := newACPTestServer(t, spritz, ownerOne, ownerTwo)
442+
s.auth.adminIDs = map[string]struct{}{"admin-1": {}}
443+
e := echo.New()
444+
secured := e.Group("", s.authMiddleware())
445+
secured.GET("/api/acp/conversations", s.listACPConversations)
446+
447+
req := httptest.NewRequest(http.MethodGet, "/api/acp/conversations?spritz=tidy-otter", nil)
448+
req.Header.Set("X-Spritz-User-Id", "admin-1")
449+
rec := httptest.NewRecorder()
450+
e.ServeHTTP(rec, req)
451+
452+
if rec.Code != http.StatusOK {
453+
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
454+
}
455+
456+
var payload struct {
457+
Status string `json:"status"`
458+
Data struct {
459+
Items []spritzv1.SpritzConversation `json:"items"`
460+
} `json:"data"`
461+
}
462+
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
463+
t.Fatalf("failed to decode list response: %v", err)
464+
}
465+
if len(payload.Data.Items) != 2 {
466+
t.Fatalf("expected 2 visible conversations for admin, got %d", len(payload.Data.Items))
467+
}
468+
}
469+
430470
func TestPatchACPConversationRejectsSessionIDMutation(t *testing.T) {
431471
spritz := readyACPSpritz("tidy-otter", "user-1")
432472
conversation := conversationFor("tidy-otter-new", "tidy-otter", "user-1", "Latest", metav1.Now())

‎api/activity.go‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"strings"
6+
"time"
7+
8+
apierrors "k8s.io/apimachinery/pkg/api/errors"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/client-go/util/retry"
11+
12+
spritzv1 "spritz.sh/operator/api/v1"
13+
)
14+
15+
const acpPromptActivityTimeout = 2 * time.Second
16+
17+
type warnLogger interface {
18+
Warnf(string, ...interface{})
19+
}
20+
21+
func (s *server) recordSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error {
22+
if s.activityRecorder != nil {
23+
return s.activityRecorder(ctx, namespace, name, when)
24+
}
25+
return s.markSpritzActivity(ctx, namespace, name, when)
26+
}
27+
28+
func (s *server) scheduleACPPromptActivity(logger warnLogger, namespace, name string, payload []byte) {
29+
if !isACPPromptMessage(payload) {
30+
return
31+
}
32+
when := time.Now()
33+
go func() {
34+
ctx, cancel := context.WithTimeout(context.Background(), acpPromptActivityTimeout)
35+
defer cancel()
36+
if err := s.recordSpritzActivity(ctx, namespace, name, when); err != nil && logger != nil {
37+
logger.Warnf("failed to record acp activity for %s/%s: %v", namespace, name, err)
38+
}
39+
}()
40+
}
41+
42+
func (s *server) markSpritzActivity(ctx context.Context, namespace, name string, when time.Time) error {
43+
if strings.TrimSpace(name) == "" {
44+
return nil
45+
}
46+
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
47+
current := &spritzv1.Spritz{}
48+
if err := s.client.Get(ctx, clientKey(namespace, name), current); err != nil {
49+
if apierrors.IsNotFound(err) {
50+
return nil
51+
}
52+
return err
53+
}
54+
timestamp := metav1.NewTime(when.UTC())
55+
if current.Status.LastActivityAt != nil && !current.Status.LastActivityAt.Time.Before(timestamp.Time) {
56+
return nil
57+
}
58+
current.Status.LastActivityAt = &timestamp
59+
return s.client.Status().Update(ctx, current)
60+
})
61+
}

0 commit comments

Comments
 (0)