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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ backend/*.db
# dev setup
/dev/docker/traefik/certificates
docker-compose.override.yml

# subagent-driven-development scratch state (progress ledger, briefs, reports)
/.superpowers/
2 changes: 1 addition & 1 deletion backend/pkg/automation/automation.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func New(graph GraphClient, db *localdb.DB, log *slog.Logger) *Service {
}

func toStatus(a *localdb.Automation) *model.AutomationStatus {
if a == nil {
if a == nil || a.ExpiresAt.Before(time.Now()) {
return &model.AutomationStatus{Connected: false}
}
return &model.AutomationStatus{
Expand Down
84 changes: 84 additions & 0 deletions backend/pkg/automation/automation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package automation

import (
"testing"
"time"

"github.com/owncloud/ocis-workflows/pkg/localdb"
)

func TestStatusReportsConnectedForFutureExpiry(t *testing.T) {
db := testDB(t)
ctx := t.Context()

expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second)
if err := db.UpsertAutomation(ctx, localdb.Automation{
UserID: "user-1",
Username: "admin",
AppPassword: "still-fresh",
ExpiresAt: expiresAt,
ConnectedAt: time.Now(),
}); err != nil {
t.Fatalf("UpsertAutomation: %v", err)
}

svc := New(&fakeGraphClient{}, db, discardLogger())

status, err := svc.Status(ctx, "user-1")
if err != nil {
t.Fatalf("Status: %v", err)
}
if !status.Connected {
t.Fatalf("Connected = false, want true")
}
want := expiresAt.UTC().Format(time.RFC3339)
if status.ExpirationDateTime != want {
t.Fatalf("ExpirationDateTime = %q, want %q", status.ExpirationDateTime, want)
}
}

func TestStatusReportsDisconnectedForPastExpiry(t *testing.T) {
db := testDB(t)
ctx := t.Context()

if err := db.UpsertAutomation(ctx, localdb.Automation{
UserID: "user-1",
Username: "admin",
AppPassword: "expired-in-place",
ExpiresAt: time.Now().Add(-time.Hour), // already expired, but the row was never deleted
ConnectedAt: time.Now().Add(-100 * 24 * time.Hour),
}); err != nil {
t.Fatalf("UpsertAutomation: %v", err)
}

svc := New(&fakeGraphClient{}, db, discardLogger())

status, err := svc.Status(ctx, "user-1")
if err != nil {
t.Fatalf("Status: %v", err)
}
if status.Connected {
t.Fatalf("Connected = true, want false for a past-expiry credential")
}
if status.ExpirationDateTime != "" {
t.Fatalf("ExpirationDateTime = %q, want empty", status.ExpirationDateTime)
}
}

func TestStatusReportsDisconnectedWhenNeverConnected(t *testing.T) {
db := testDB(t)
ctx := t.Context()

svc := New(&fakeGraphClient{}, db, discardLogger())

status, err := svc.Status(ctx, "never-connected-user")
if err != nil {
t.Fatalf("Status: %v", err)
}
if status.Connected {
t.Fatalf("Connected = true, want false for a user with no stored automation")
}
if status.ExpirationDateTime != "" {
t.Fatalf("ExpirationDateTime = %q, want empty", status.ExpirationDateTime)
}
}
81 changes: 81 additions & 0 deletions backend/pkg/automation/renew.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package automation

import (
"context"
"encoding/base64"
"fmt"
"time"

"github.com/owncloud/ocis-workflows/pkg/localdb"
)

// renewalWindow is how close to expiry a stored automation must be before StartRenewalLoop
// mints a replacement. 14 days gives plenty of margin against a daily sweep interval, well
// within the 90-day defaultExpiry.
const renewalWindow = 14 * 24 * time.Hour

// StartRenewalLoop blocks, checking for automations nearing expiry every interval, until ctx
// is done. Renewal happens entirely server-side — no live user session is involved, only the
// stored app-password itself (see renewOne) — so background execution keeps working
// indefinitely without the user ever needing to revisit the app.
func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.renewDue(ctx)
}
}
}

func (s *Service) renewDue(ctx context.Context) {
automations, err := s.db.ListAutomations(ctx)
if err != nil {
s.log.Error("automation: list automations for renewal", "error", err)
return
}

now := time.Now()
for _, a := range automations {
if a.ExpiresAt.Sub(now) > renewalWindow {
continue
}
s.renewOne(ctx, a)
}
}

// renewOne mints a replacement app-password for a, authenticating with a's own stored
// app-password over Basic auth — the same auth header the scheduler builds to run workflows
// (see scheduler.runOne) — rather than any live bearer token.
func (s *Service) renewOne(ctx context.Context, a localdb.Automation) {
authHeader := "Basic " + base64.StdEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", a.Username, a.AppPassword))

token, expiresAt, err := s.graph.MintAppPassword(ctx, authHeader, defaultExpiry, tokenLabel)
if err != nil {
s.log.Error("automation: renew app password", "userID", a.UserID, "error", err)
return
}

renewed := localdb.Automation{
UserID: a.UserID,
Username: a.Username,
AppPassword: token,
ExpiresAt: expiresAt,
ConnectedAt: a.ConnectedAt,
}
if err := s.db.UpsertAutomation(ctx, renewed); err != nil {
s.log.Error("automation: store renewed app password", "userID", a.UserID, "error", err)
return
}

// Best-effort — the old token being unrevokable (already expired/invalidated
// out-of-band) shouldn't undo the renewal we just successfully stored.
if err := s.graph.RevokeAppPassword(ctx, authHeader, a.AppPassword); err != nil {
s.log.Warn("automation: revoke old app password after renewal, ignoring", "userID", a.UserID, "error", err)
}

s.log.Info("automation: renewed app password", "userID", a.UserID, "expiresAt", expiresAt)
}
187 changes: 187 additions & 0 deletions backend/pkg/automation/renew_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package automation

import (
"context"
"encoding/base64"
"errors"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"

"github.com/owncloud/ocis-workflows/pkg/localdb"
)

func testDB(t *testing.T) *localdb.DB {
t.Helper()
db, err := localdb.Open(filepath.Join(t.TempDir(), "test.db"), make([]byte, 32))
if err != nil {
t.Fatalf("localdb.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}

func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(discardWriter{}, nil))
}

type discardWriter struct{}

func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }

type fakeGraphClient struct {
mintCalls []string // authHeader values MintAppPassword was called with
mintToken string
mintExpiry time.Time
mintErr error

revokeCalls []string // old-password values RevokeAppPassword was called with
}

func (f *fakeGraphClient) Me(context.Context, string) (string, error) { return "", nil }
func (f *fakeGraphClient) Username(context.Context, string) (string, error) { return "", nil }

func (f *fakeGraphClient) MintAppPassword(_ context.Context, authHeader string, _ time.Duration, _ string) (string, time.Time, error) {
f.mintCalls = append(f.mintCalls, authHeader)
if f.mintErr != nil {
return "", time.Time{}, f.mintErr
}
return f.mintToken, f.mintExpiry, nil
}

func (f *fakeGraphClient) RevokeAppPassword(_ context.Context, _, token string) error {
f.revokeCalls = append(f.revokeCalls, token)
return nil
}

func TestRenewDueRenewsAutomationNearingExpiry(t *testing.T) {
db := testDB(t)
ctx := t.Context()

if err := db.UpsertAutomation(ctx, localdb.Automation{
UserID: "user-1",
Username: "admin",
AppPassword: "old-password",
ExpiresAt: time.Now().Add(10 * 24 * time.Hour), // within the 14-day renewal window
ConnectedAt: time.Now().Add(-80 * 24 * time.Hour),
}); err != nil {
t.Fatalf("UpsertAutomation: %v", err)
}

newExpiry := time.Now().Add(defaultExpiry).Truncate(time.Second)
graph := &fakeGraphClient{mintToken: "new-password", mintExpiry: newExpiry}
svc := New(graph, db, discardLogger())

svc.renewDue(ctx)

if len(graph.mintCalls) != 1 {
t.Fatalf("expected 1 MintAppPassword call, got %d", len(graph.mintCalls))
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("admin:old-password"))
if graph.mintCalls[0] != wantAuth {
t.Fatalf("MintAppPassword authHeader = %q, want %q", graph.mintCalls[0], wantAuth)
}

got, err := db.GetAutomation(ctx, "user-1")
if err != nil {
t.Fatalf("GetAutomation: %v", err)
}
if got.AppPassword != "new-password" {
t.Fatalf("AppPassword after renewal = %q, want %q", got.AppPassword, "new-password")
}
if !got.ExpiresAt.Equal(newExpiry) {
t.Fatalf("ExpiresAt after renewal = %v, want %v", got.ExpiresAt, newExpiry)
}
if len(graph.revokeCalls) != 1 || graph.revokeCalls[0] != "old-password" {
t.Fatalf("expected RevokeAppPassword to be called with the old password, got %v", graph.revokeCalls)
}
}

func TestRenewDueSkipsAutomationNotNearingExpiry(t *testing.T) {
db := testDB(t)
ctx := t.Context()

if err := db.UpsertAutomation(ctx, localdb.Automation{
UserID: "user-1",
Username: "admin",
AppPassword: "still-fresh",
ExpiresAt: time.Now().Add(60 * 24 * time.Hour), // well outside the 14-day window
ConnectedAt: time.Now(),
}); err != nil {
t.Fatalf("UpsertAutomation: %v", err)
}

graph := &fakeGraphClient{}
svc := New(graph, db, discardLogger())

svc.renewDue(ctx)

if len(graph.mintCalls) != 0 {
t.Fatalf("expected 0 MintAppPassword calls, got %d", len(graph.mintCalls))
}
got, err := db.GetAutomation(ctx, "user-1")
if err != nil {
t.Fatalf("GetAutomation: %v", err)
}
if got.AppPassword != "still-fresh" {
t.Fatalf("AppPassword changed unexpectedly: %q", got.AppPassword)
}
}

type selectiveFailGraphClient struct {
failForUsername string
mintToken string
mintExpiry time.Time
}

func (f *selectiveFailGraphClient) Me(context.Context, string) (string, error) { return "", nil }
func (f *selectiveFailGraphClient) Username(context.Context, string) (string, error) { return "", nil }

func (f *selectiveFailGraphClient) MintAppPassword(_ context.Context, authHeader string, _ time.Duration, _ string) (string, time.Time, error) {
decoded, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Basic "))
username := strings.SplitN(string(decoded), ":", 2)[0]
if username == f.failForUsername {
return "", time.Time{}, errors.New("simulated mint failure")
}
return f.mintToken, f.mintExpiry, nil
}

func (f *selectiveFailGraphClient) RevokeAppPassword(context.Context, string, string) error { return nil }

func TestRenewDueContinuesPastAFailedRenewal(t *testing.T) {
db := testDB(t)
ctx := t.Context()

for _, a := range []localdb.Automation{
{UserID: "user-fails", Username: "admin", AppPassword: "will-fail", ExpiresAt: time.Now().Add(time.Hour), ConnectedAt: time.Now()},
{UserID: "user-ok", Username: "marie", AppPassword: "will-succeed", ExpiresAt: time.Now().Add(time.Hour), ConnectedAt: time.Now()},
} {
if err := db.UpsertAutomation(ctx, a); err != nil {
t.Fatalf("UpsertAutomation(%s): %v", a.UserID, err)
}
}

newExpiry := time.Now().Add(defaultExpiry).Truncate(time.Second)
graph := &selectiveFailGraphClient{failForUsername: "admin", mintToken: "renewed", mintExpiry: newExpiry}
svc := New(graph, db, discardLogger())

svc.renewDue(ctx) // must not panic or stop early

failed, err := db.GetAutomation(ctx, "user-fails")
if err != nil {
t.Fatalf("GetAutomation(user-fails): %v", err)
}
if failed.AppPassword != "will-fail" {
t.Fatalf("expected user-fails' password to be left untouched, got %q", failed.AppPassword)
}

ok, err := db.GetAutomation(ctx, "user-ok")
if err != nil {
t.Fatalf("GetAutomation(user-ok): %v", err)
}
if ok.AppPassword != "renewed" {
t.Fatalf("expected user-ok to be renewed, got %q", ok.AppPassword)
}
}
11 changes: 11 additions & 0 deletions backend/pkg/command/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const scheduleTickInterval = 10 * time.Second
// active event-trigger consumer.
const sseReconcileInterval = 30 * time.Second

// renewalTickInterval controls how often the automation service checks for app-passwords
// nearing expiry. Daily is frequent enough given the 14-day renewal window and 90-day
// credential lifetime.
const renewalTickInterval = 24 * time.Hour

// RunServer starts the public API server, the debug server, and the background schedule
// evaluator, and blocks until any of them exits or the process receives an interrupt/
// termination signal.
Expand Down Expand Up @@ -109,6 +114,12 @@ func RunServer(cfg config.Config) error {
return nil
})

g.Go(func() error {
log.Info("starting automation renewal loop", "interval", renewalTickInterval)
automationService.StartRenewalLoop(gCtx, renewalTickInterval)
return nil
})

g.Go(func() error {
<-gCtx.Done()
log.Info("shutting down")
Expand Down
Loading
Loading