Skip to content
Draft
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
1 change: 1 addition & 0 deletions cmd/engram/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ func cloudRuntimeServerOptions(cfg cloud.Config, cs *cloudstore.CloudStore, allo
cloudserver.WithPrincipalStateStore(cs),
cloudserver.WithDashboardAdminToken(cfg.AdminToken),
cloudserver.WithMaxPushBodyBytes(cfg.MaxPushBodyBytes),
cloudserver.WithDashboardEnableDelete(cfg.DashboardEnableDelete),
cloudserver.WithSyncStatusProvider(cloudDashboardStatusProvider{store: cs, projects: allowedProjects}),
}
if authenticator != nil {
Expand Down
18 changes: 18 additions & 0 deletions cmd/engram/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,26 @@ import (
mcpserver "github.com/mark3labs/mcp-go/server"
)

func clearTestEnvVars(t *testing.T) {
t.Helper()
for _, key := range []string{
"ENGRAM_PROJECT",
"ENGRAM_CLOUD_SERVER",
"ENGRAM_CLOUD_TOKEN",
"ENGRAM_CLOUD_INSECURE_NO_AUTH",
"ENGRAM_CLOUD_ALLOWED_PROJECTS",
"ENGRAM_CLOUD_HOST",
"ENGRAM_CLOUD_PORT",
"ENGRAM_CLOUD_MAX_PUSH_BYTES",
"ENGRAM_JWT_SECRET",
} {
t.Setenv(key, "")
}
}

func testConfig(t *testing.T) store.Config {
t.Helper()
clearTestEnvVars(t)
cfg, err := store.DefaultConfig()
if err != nil {
t.Fatalf("DefaultConfig: %v", err)
Expand Down
9 changes: 9 additions & 0 deletions internal/cloud/cloudserver/cloudserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type CloudServer struct {
port int
host string
maxPushBodyBytes int64
dashboardEnableDelete bool
mux *http.ServeMux
syncStatus dashboard.SyncStatusProvider
listenAndServe func(addr string, handler http.Handler) error
Expand Down Expand Up @@ -145,6 +146,10 @@ func WithMaxPushBodyBytes(limit int64) Option {
}
}

func WithDashboardEnableDelete(enabled bool) Option {
return func(s *CloudServer) { s.dashboardEnableDelete = enabled }
}

func New(store ChunkStore, authSvc Authenticator, port int, opts ...Option) *CloudServer {
s := &CloudServer{
store: store,
Expand Down Expand Up @@ -256,6 +261,7 @@ func (s *CloudServer) routes() {
ManagedUsers: managedUsersStore,
MaxLoginBodyBytes: maxDashboardLoginBodyBytes,
StatusProvider: s.syncStatus,
EnableDelete: s.dashboardEnableDelete,
})
s.mux.HandleFunc("GET /dashboard/bootstrap", s.handleDashboardBootstrapPage)
s.mux.HandleFunc("POST /dashboard/bootstrap", s.handleDashboardBootstrapSubmit)
Expand Down Expand Up @@ -413,6 +419,9 @@ func (s *CloudServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
}

func (s *CloudServer) isDashboardAdmin(r *http.Request) bool {
if s.auth == nil && s.dashboardEnableDelete {
return true
}
if principal, ok := s.dashboardPrincipalFromRequest(r); ok {
return principal.Role == cloudauth.RoleAdmin && (principal.Source == cloudauth.PrincipalSourceManagedToken || principal.Source == cloudauth.PrincipalSourceLegacyEnvAdmin)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/cloud/cloudstore/audit_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const AuditActionMutationPush = "mutation_push"
// AuditActionChunkPush discriminates chunk push rejections.
const AuditActionChunkPush = "chunk_push"

const AuditActionDashboardDelete = "dashboard_delete"

// ─── Types ────────────────────────────────────────────────────────────────────

// AuditEntry is the write-side struct for inserting an audit log row.
Expand Down
27 changes: 27 additions & 0 deletions internal/cloud/cloudstore/cloudstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,33 @@ func (cs *CloudStore) InsertMutationBatch(ctx context.Context, batch []MutationE
return seqs, nil
}

func (cs *CloudStore) DeleteDashboardEntity(ctx context.Context, project, entity, entityKey, sessionID, actor string) error {
project = strings.TrimSpace(project)
entity = strings.TrimSpace(entity)
entityKey = strings.TrimSpace(entityKey)
sessionID = strings.TrimSpace(sessionID)
if project == "" || entityKey == "" || sessionID == "" {
return fmt.Errorf("cloudstore: delete target is incomplete")
}
if entity != store.SyncEntitySession && entity != store.SyncEntityObservation && entity != store.SyncEntityPrompt {
return fmt.Errorf("cloudstore: unsupported dashboard delete entity %q", entity)
}
payload := map[string]any{"deleted": true, "hard_delete": true, "session_id": sessionID}
if entity == store.SyncEntitySession {
payload["id"] = entityKey
} else {
payload["sync_id"] = entityKey
}
encoded, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("cloudstore: encode dashboard delete: %w", err)
}
if _, err := cs.InsertMutationBatch(ctx, []MutationEntry{{Project: project, Entity: entity, EntityKey: entityKey, Op: store.SyncOpDelete, Payload: encoded}}); err != nil {
return err
}
return cs.InsertAuditEntry(ctx, AuditEntry{Contributor: actor, Project: project, Action: AuditActionDashboardDelete, Outcome: "deleted", EntryCount: 1, ReasonCode: entity})
}

const mutationBackfillChunkSize = 100

func (cs *CloudStore) BackfillMutationChunks(ctx context.Context, project string, apply bool) (MutationChunkBackfillReport, error) {
Expand Down
4 changes: 4 additions & 0 deletions internal/cloud/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Config struct {
AdminToken string
AllowedProjects []string
MaxPushBodyBytes int64
DashboardEnableDelete bool
// TokenPepper is the dedicated secret used to hash managed cloud tokens
// (see internal/cloud/auth.ManagedTokenHasher). It MUST be distinct from
// JWTSecret so rotating the dashboard/session signing secret does not
Expand Down Expand Up @@ -86,5 +87,8 @@ func ConfigFromEnv() Config {
}
cfg.AllowedProjects = projects
}
if v := strings.TrimSpace(os.Getenv("ENGRAM_DASHBOARD_ENABLE_DELETE")); v != "" {
cfg.DashboardEnableDelete = v == "1" || strings.EqualFold(v, "true")
}
return cfg
}
25 changes: 22 additions & 3 deletions internal/cloud/dashboard/components.templ
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ templ PromptsPartial(prompts []cloudstore.DashboardPromptRow, pg Pagination) {

// PromptDetailPage renders a prompt detail page.
// ADAPTED: CloudPrompt -> DashboardPromptRow; CloudSession -> DashboardSessionRow.
templ PromptDetailPage(prompt *cloudstore.DashboardPromptRow, session *cloudstore.DashboardSessionRow, related []cloudstore.DashboardPromptRow) {
templ PromptDetailPage(prompt *cloudstore.DashboardPromptRow, session *cloudstore.DashboardSessionRow, related []cloudstore.DashboardPromptRow, showDelete bool) {
<section class="frame-section">
<div class="section-header">
<div>
Expand All @@ -445,6 +445,9 @@ templ PromptDetailPage(prompt *cloudstore.DashboardPromptRow, session *cloudstor
@templ.Raw(renderStructuredContent(prompt.Content))
</div>
</div>
if showDelete {
@DashboardDeleteForm("prompt", prompt.Project, prompt.SyncID, prompt.SessionID)
}
if session != nil {
<div class="data-frame linked-session-panel">
<p class="section-kicker">LINKED SESSION</p>
Expand Down Expand Up @@ -1208,7 +1211,7 @@ templ AdminSyncToggleFormPartial(control cloudstore.ProjectSyncControl) {

// SessionDetailPage renders a connected session detail surface.
// ADAPTED: CloudSession -> DashboardSessionRow; CloudObservation -> DashboardObservationRow; CloudPrompt -> DashboardPromptRow.
templ SessionDetailPage(session *cloudstore.DashboardSessionRow, observations []cloudstore.DashboardObservationRow, prompts []cloudstore.DashboardPromptRow) {
templ SessionDetailPage(session *cloudstore.DashboardSessionRow, observations []cloudstore.DashboardObservationRow, prompts []cloudstore.DashboardPromptRow, showDelete bool) {
<section class="frame-section">
<div class="section-header">
<div>
Expand Down Expand Up @@ -1267,6 +1270,9 @@ templ SessionDetailPage(session *cloudstore.DashboardSessionRow, observations []
</tbody>
</table>
</div>
if showDelete {
@DashboardDeleteForm("session", session.Project, session.SessionID, session.SessionID)
}
<h3 class="section-block-title">Observations in this Session</h3>
@ObservationsPartial(observations, Pagination{})
<h3 class="section-block-title">Prompts in this Session</h3>
Expand All @@ -1277,7 +1283,7 @@ templ SessionDetailPage(session *cloudstore.DashboardSessionRow, observations []

// ObservationDetailPage renders the full observation payload with session context.
// ADAPTED: CloudObservation -> DashboardObservationRow; CloudSession -> DashboardSessionRow.
templ ObservationDetailPage(observation *cloudstore.DashboardObservationRow, session *cloudstore.DashboardSessionRow, related []cloudstore.DashboardObservationRow) {
templ ObservationDetailPage(observation *cloudstore.DashboardObservationRow, session *cloudstore.DashboardSessionRow, related []cloudstore.DashboardObservationRow, showDelete bool) {
<section class="frame-section">
<div class="section-header">
<div>
Expand Down Expand Up @@ -1329,6 +1335,9 @@ templ ObservationDetailPage(observation *cloudstore.DashboardObservationRow, ses
@templ.Raw(renderStructuredContent(observation.Content))
</div>
</div>
if session != nil && showDelete {
@DashboardDeleteForm("observation", observation.Project, observation.SyncID, observation.SessionID)
}
if session != nil {
<div class="data-frame linked-session-panel">
<p class="section-kicker">LINKED SESSION</p>
Expand All @@ -1347,6 +1356,16 @@ templ ObservationDetailPage(observation *cloudstore.DashboardObservationRow, ses
</section>
}

templ DashboardDeleteForm(entity, project, entityKey, sessionID string) {
<form method="post" action="/dashboard/delete" onsubmit="return confirm('Delete this item?')">
<input type="hidden" name="entity" value={ entity }/>
<input type="hidden" name="project" value={ project }/>
<input type="hidden" name="entity_key" value={ entityKey }/>
<input type="hidden" name="session_id" value={ sessionID }/>
<button type="submit" class="delete-button">Delete</button>
</form>
}

// ─── Phase 9: Audit Log Admin Views ─────────────────────────────────────────

// AdminAuditLogPage renders the admin audit log shell page.
Expand Down
Loading