Skip to content
Open
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
8 changes: 4 additions & 4 deletions internal/handler/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func (a *API) updateAgentOp(ctx context.Context, input *UpdateAgentInput) (*GetA
return nil, huma.Error401Unauthorized("missing tenant context")
}

resp, err := a.agentSvc.UpdateAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID, service.UpdateAgentRequest{
resp, err := a.agentSvc.UpdateAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID, internalMiddleware.GetCallerName(ctx), service.UpdateAgentRequest{
Name: input.Body.Name,
SubType: input.Body.SubType,
TrustLevel: input.Body.TrustLevel,
Expand All @@ -286,7 +286,7 @@ func (a *API) deleteAgentOp(ctx context.Context, input *AgentActionInput) (*Agen
return nil, huma.Error401Unauthorized("missing tenant context")
}

resp, err := a.agentSvc.DeleteAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID)
resp, err := a.agentSvc.DeleteAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID, internalMiddleware.GetCallerName(ctx))
if err != nil {
return nil, mapErr(err)
}
Expand All @@ -300,7 +300,7 @@ func (a *API) activateAgentOp(ctx context.Context, input *AgentActionInput) (*Ag
return nil, huma.Error401Unauthorized("missing tenant context")
}

resp, err := a.agentSvc.ActivateAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID)
resp, err := a.agentSvc.ActivateAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID, internalMiddleware.GetCallerName(ctx))
if err != nil {
return nil, mapErr(err)
}
Expand All @@ -314,7 +314,7 @@ func (a *API) deactivateAgentOp(ctx context.Context, input *AgentActionInput) (*
return nil, huma.Error401Unauthorized("missing tenant context")
}

resp, err := a.agentSvc.DeactivateAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID)
resp, err := a.agentSvc.DeactivateAgent(ctx, input.ID, tenant.AccountID, tenant.ProjectID, internalMiddleware.GetCallerName(ctx))
if err != nil {
return nil, mapErr(err)
}
Expand Down
8 changes: 5 additions & 3 deletions internal/handler/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ func (a *API) createIdentityOp(ctx context.Context, input *CreateIdentityInput)
return nil, huma.Error400BadRequest("invalid sub_type for the given identity_type")
}

createdBy := internalMiddleware.GetCallerName(ctx)
callerUserID := internalMiddleware.GetCallerName(ctx)

identity, err := a.identitySvc.RegisterIdentity(ctx, service.RegisterIdentityRequest{
AccountID: tenant.AccountID,
Expand All @@ -189,7 +189,8 @@ func (a *API) createIdentityOp(ctx context.Context, input *CreateIdentityInput)
Description: input.Body.Description,
Capabilities: input.Body.Capabilities,
Labels: input.Body.Labels,
CreatedBy: createdBy,
CreatedBy: callerUserID,
CallerUserID: callerUserID,
})
if err != nil {
if errors.Is(err, service.ErrIdentityAlreadyExists) {
Expand Down Expand Up @@ -292,6 +293,7 @@ func (a *API) updateIdentityOp(ctx context.Context, input *UpdateIdentityInput)
Labels: input.Body.Labels,
Metadata: input.Body.Metadata,
Status: status,
CallerUserID: internalMiddleware.GetCallerName(ctx),
})
if err != nil {
log.Error().Err(err).Str("identity_id", input.ID).Msg("failed to update identity")
Expand All @@ -307,7 +309,7 @@ func (a *API) deleteIdentityOp(ctx context.Context, input *IdentityIDInput) (*st
return nil, huma.Error401Unauthorized("missing tenant context")
}

if err := a.identitySvc.DeleteIdentity(ctx, input.ID, tenant.AccountID, tenant.ProjectID); err != nil {
if err := a.identitySvc.DeleteIdentity(ctx, input.ID, tenant.AccountID, tenant.ProjectID, internalMiddleware.GetCallerName(ctx)); err != nil {
log.Error().Err(err).Str("identity_id", input.ID).Msg("failed to delete identity")
return nil, huma.Error500InternalServerError("failed to delete identity")
}
Expand Down
19 changes: 11 additions & 8 deletions internal/service/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ func (s *AgentService) RegisterAgent(ctx context.Context, req RegisterAgentReque
})
if err != nil {
// Compensating action — deactivate the identity if key creation fails.
_ = s.identitySvc.DeleteIdentity(ctx, identity.ID, req.AccountID, req.ProjectID)
_ = s.identitySvc.DeleteIdentity(ctx, identity.ID, req.AccountID, req.ProjectID, req.CreatedBy)
return nil, fmt.Errorf("failed to create API key: %w", err)
}

Expand Down Expand Up @@ -201,7 +201,7 @@ func (s *AgentService) ListAgents(ctx context.Context, accountID, projectID stri
}

// UpdateAgent updates an agent identity with PATCH semantics.
func (s *AgentService) UpdateAgent(ctx context.Context, id, accountID, projectID string, req UpdateAgentRequest) (*AgentResponse, error) {
func (s *AgentService) UpdateAgent(ctx context.Context, id, accountID, projectID, callerUserID string, req UpdateAgentRequest) (*AgentResponse, error) {
var subType domain.SubType
if req.SubType != nil {
subType = domain.SubType(*req.SubType)
Expand Down Expand Up @@ -229,6 +229,7 @@ func (s *AgentService) UpdateAgent(ctx context.Context, id, accountID, projectID
Labels: req.Labels,
Metadata: req.Metadata,
Status: status,
CallerUserID: callerUserID,
})
if err != nil {
return nil, err
Expand All @@ -240,14 +241,14 @@ func (s *AgentService) UpdateAgent(ctx context.Context, id, accountID, projectID
}

// DeleteAgent deactivates an agent and revokes its keys.
func (s *AgentService) DeleteAgent(ctx context.Context, id, accountID, projectID string) (*AgentResponse, error) {
func (s *AgentService) DeleteAgent(ctx context.Context, id, accountID, projectID, callerUserID string) (*AgentResponse, error) {
identity, err := s.identitySvc.GetIdentity(ctx, id, accountID, projectID)
if err != nil {
return nil, err
}

// Hard delete — cascades to api_keys, credentials, etc. via FK ON DELETE CASCADE.
if err := s.identitySvc.DeleteIdentity(ctx, id, accountID, projectID); err != nil {
if err := s.identitySvc.DeleteIdentity(ctx, id, accountID, projectID, callerUserID); err != nil {
return nil, err
}

Expand All @@ -256,10 +257,11 @@ func (s *AgentService) DeleteAgent(ctx context.Context, id, accountID, projectID
}

// ActivateAgent enables a previously deactivated agent.
func (s *AgentService) ActivateAgent(ctx context.Context, id, accountID, projectID string) (*AgentResponse, error) {
func (s *AgentService) ActivateAgent(ctx context.Context, id, accountID, projectID, callerUserID string) (*AgentResponse, error) {
status := domain.IdentityStatusActive
identity, err := s.identitySvc.UpdateIdentity(ctx, id, accountID, projectID, UpdateIdentityRequest{
Status: &status,
Status: &status,
CallerUserID: callerUserID,
})
if err != nil {
return nil, err
Expand All @@ -271,10 +273,11 @@ func (s *AgentService) ActivateAgent(ctx context.Context, id, accountID, project
}

// DeactivateAgent disables an agent without deleting it.
func (s *AgentService) DeactivateAgent(ctx context.Context, id, accountID, projectID string) (*AgentResponse, error) {
func (s *AgentService) DeactivateAgent(ctx context.Context, id, accountID, projectID, callerUserID string) (*AgentResponse, error) {
status := domain.IdentityStatusDeactivated
identity, err := s.identitySvc.UpdateIdentity(ctx, id, accountID, projectID, UpdateIdentityRequest{
Status: &status,
Status: &status,
CallerUserID: callerUserID,
})
if err != nil {
return nil, err
Expand Down
58 changes: 54 additions & 4 deletions internal/service/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,26 @@ import (
"github.com/highflame-ai/zeroid/internal/store/postgres"
)

const (
auditActionCreate = "CREATE"
auditActionUpdate = "UPDATE"
auditActionDelete = "DELETE"
auditStatusOK = "SUCCESS"
)

// ErrIdentityAlreadyExists is returned when (account_id, project_id, external_id) already exists.
var ErrIdentityAlreadyExists = errors.New("identity already exists")

// IdentityService handles identity lifecycle operations.
type IdentityService struct {
repo *postgres.IdentityRepository
auditRepo *postgres.AuditRepository
wimseDomain string
}

// NewIdentityService creates a new IdentityService.
func NewIdentityService(repo *postgres.IdentityRepository, wimseDomain string) *IdentityService {
return &IdentityService{repo: repo, wimseDomain: wimseDomain}
func NewIdentityService(repo *postgres.IdentityRepository, auditRepo *postgres.AuditRepository, wimseDomain string) *IdentityService {
return &IdentityService{repo: repo, auditRepo: auditRepo, wimseDomain: wimseDomain}
}

// validateECPublicKeyPEM ensures the provided PEM string is a valid EC P-256 public key.
Expand Down Expand Up @@ -75,6 +83,8 @@ type RegisterIdentityRequest struct {
Labels json.RawMessage
Metadata json.RawMessage
CreatedBy string
// CallerUserID is the acting user (from X-User-ID header) — who registered this identity.
CallerUserID string
}

// RegisterIdentity creates a new identity with a WIMSE URI.
Expand Down Expand Up @@ -157,6 +167,13 @@ func (s *IdentityService) RegisterIdentity(ctx context.Context, req RegisterIden
Str("wimse_uri", identity.WIMSEURI).
Msg("Identity registered")

if s.auditRepo != nil {
newSnap, _ := json.Marshal(identity)
if err := s.auditRepo.Insert(ctx, req.AccountID, req.ProjectID, req.CallerUserID, identity.ID, auditActionCreate, auditStatusOK, nil, newSnap); err != nil {
Comment thread
darshana-v marked this conversation as resolved.
log.Error().Err(err).Str("identity_id", identity.ID).Msg("failed to write identity audit log")
}
}

return identity, nil
}

Expand Down Expand Up @@ -193,6 +210,8 @@ type UpdateIdentityRequest struct {
Labels json.RawMessage
Metadata json.RawMessage
Status *domain.IdentityStatus
// CallerUserID is the acting user (from X-User-ID header) — who made this update.
CallerUserID string
}

// UpdateIdentity updates mutable fields of an existing identity.
Expand All @@ -201,6 +220,12 @@ func (s *IdentityService) UpdateIdentity(ctx context.Context, id, accountID, pro
if err != nil {
return nil, err
}

// Snapshot the identity before mutation for the audit log.
var oldSnap json.RawMessage
if s.auditRepo != nil {
oldSnap, _ = json.Marshal(identity)
}
if req.Name != "" {
identity.Name = req.Name
}
Expand Down Expand Up @@ -262,6 +287,14 @@ func (s *IdentityService) UpdateIdentity(ctx context.Context, id, accountID, pro
if err := s.repo.Update(ctx, identity); err != nil {
return nil, err
}

if s.auditRepo != nil {
newSnap, _ := json.Marshal(identity)
if err := s.auditRepo.Insert(ctx, accountID, projectID, req.CallerUserID, id, auditActionUpdate, auditStatusOK, oldSnap, newSnap); err != nil {
log.Error().Err(err).Str("identity_id", id).Msg("failed to write identity audit log")
}
}

return identity, nil
}

Expand Down Expand Up @@ -303,6 +336,23 @@ func (s *IdentityService) EnsureServiceIdentity(ctx context.Context, accountID,
}

// DeleteIdentity permanently removes an identity and cascades to related records.
func (s *IdentityService) DeleteIdentity(ctx context.Context, id, accountID, projectID string) error {
return s.repo.Delete(ctx, id, accountID, projectID)
func (s *IdentityService) DeleteIdentity(ctx context.Context, id, accountID, projectID, callerUserID string) error {
var oldSnap json.RawMessage
if s.auditRepo != nil {
if existing, err := s.repo.GetByID(ctx, id, accountID, projectID); err == nil {
oldSnap, _ = json.Marshal(existing)
}
}

if err := s.repo.Delete(ctx, id, accountID, projectID); err != nil {
return err
}

if s.auditRepo != nil {
if err := s.auditRepo.Insert(ctx, accountID, projectID, callerUserID, id, auditActionDelete, auditStatusOK, oldSnap, nil); err != nil {
log.Error().Err(err).Str("identity_id", id).Msg("failed to write identity audit log")
}
}

return nil
}
54 changes: 54 additions & 0 deletions internal/store/postgres/audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package postgres

import (
"context"
"encoding/json"
"time"

"github.com/google/uuid"
"github.com/uptrace/bun"
)

// IdentityAuditLog is the DB model for the identity_audit_logs table.
type IdentityAuditLog struct {
bun.BaseModel `bun:"table:identity_audit_logs,alias:ial"`

ID string `bun:"id,pk,type:uuid,default:gen_random_uuid()"`
AccountID string `bun:"account_id,notnull"`
ProjectID string `bun:"project_id,notnull"`
CallerUserID string `bun:"caller_user_id,notnull"`
IdentityID string `bun:"identity_id,notnull"`
Action string `bun:"action,notnull"`
Status string `bun:"status,notnull"`
OldData json.RawMessage `bun:"old_data,type:jsonb"`
NewData json.RawMessage `bun:"new_data,type:jsonb"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
}

// AuditRepository handles writes to the identity_audit_logs table.
type AuditRepository struct {
db *bun.DB
}

// NewAuditRepository creates a new AuditRepository.
func NewAuditRepository(db *bun.DB) *AuditRepository {
return &AuditRepository{db: db}
}

// Insert writes a single audit record.
func (r *AuditRepository) Insert(ctx context.Context, accountID, projectID, callerUserID, identityID, action, status string, oldData, newData json.RawMessage) error {
entry := &IdentityAuditLog{
ID: uuid.New().String(),
AccountID: accountID,
ProjectID: projectID,
CallerUserID: callerUserID,
IdentityID: identityID,
Action: action,
Status: status,
OldData: oldData,
NewData: newData,
CreatedAt: time.Now().UTC(),
}
_, err := r.db.NewInsert().Model(entry).Exec(ctx)
return err
}
2 changes: 2 additions & 0 deletions migrations/008_identity_audit_logs.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- 008_identity_audit_logs.down.sql
DROP TABLE IF EXISTS identity_audit_logs;
30 changes: 30 additions & 0 deletions migrations/008_identity_audit_logs.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- 008_identity_audit_logs.up.sql
-- Records who performed each identity create/update/delete operation.
-- caller_user_id is the acting user (from X-User-ID header), not the identity's owner_user_id.

CREATE TABLE IF NOT EXISTS identity_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
account_id VARCHAR(255) NOT NULL,
project_id VARCHAR(255) NOT NULL DEFAULT '',
caller_user_id VARCHAR(255) NOT NULL DEFAULT '',
identity_id VARCHAR(255) NOT NULL DEFAULT '',
action VARCHAR(50) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'SUCCESS',
old_data JSONB,
new_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_identity_audit_logs_tenant
ON identity_audit_logs (account_id, project_id);

CREATE INDEX IF NOT EXISTS idx_identity_audit_logs_identity
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove second and fourth indexes

ON identity_audit_logs (identity_id)
WHERE identity_id != '';

CREATE INDEX IF NOT EXISTS idx_identity_audit_logs_caller
ON identity_audit_logs (caller_user_id)
WHERE caller_user_id != '';

CREATE INDEX IF NOT EXISTS idx_identity_audit_logs_created_at
ON identity_audit_logs (created_at DESC);
3 changes: 2 additions & 1 deletion server.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ func NewServer(cfg Config) (*Server, error) {

// Initialize repositories.
identityRepo := postgres.NewIdentityRepository(db)
auditRepo := postgres.NewAuditRepository(db)
credentialRepo := postgres.NewCredentialRepository(db)
attestationRepo := postgres.NewAttestationRepository(db)
signalRepo := postgres.NewSignalRepository(db)
Expand All @@ -146,7 +147,7 @@ func NewServer(cfg Config) (*Server, error) {
refreshTokenRepo := postgres.NewRefreshTokenRepository(db)

// Initialize services.
identitySvc := service.NewIdentityService(identityRepo, cfg.WIMSEDomain)
identitySvc := service.NewIdentityService(identityRepo, auditRepo, cfg.WIMSEDomain)
credentialPolicySvc := service.NewCredentialPolicyService(credentialPolicyRepo)
credentialSvc := service.NewCredentialService(credentialRepo, jwksSvc, credentialPolicySvc, attestationRepo, cfg.Token.Issuer, cfg.Token.DefaultTTL, cfg.Token.MaxTTL)
attestationSvc := service.NewAttestationService(attestationRepo, credentialSvc, identitySvc)
Expand Down
Loading