feat(compliance): add content compliance detection across tenant, ops, and clients
Implements the Revision 8/9 compliance design: tenant + ops backends, ops-web content-safety pages, admin-web editor/publish gate integration, desktop publish block surfacing, and supporting migrations / shared types / config plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,18 +15,22 @@ import (
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||
tenantcompliance "github.com/geo-platform/tenant-api/internal/tenant/compliance"
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
type PublishJobService struct {
|
||||
pool *pgxpool.Pool
|
||||
messaging *rabbitmq.Client
|
||||
redis *goredis.Client
|
||||
logger *zap.Logger
|
||||
cache sharedcache.Cache
|
||||
pool *pgxpool.Pool
|
||||
messaging *rabbitmq.Client
|
||||
redis *goredis.Client
|
||||
logger *zap.Logger
|
||||
cache sharedcache.Cache
|
||||
compliance *tenantcompliance.Service
|
||||
}
|
||||
|
||||
func NewPublishJobService(
|
||||
@@ -33,12 +38,25 @@ func NewPublishJobService(
|
||||
messaging *rabbitmq.Client,
|
||||
redis *goredis.Client,
|
||||
logger *zap.Logger,
|
||||
) *PublishJobService {
|
||||
return NewPublishJobServiceWithConfig(pool, messaging, redis, logger, config.NewStaticProvider(&config.Config{
|
||||
Compliance: config.ComplianceConfig{Enabled: true},
|
||||
}))
|
||||
}
|
||||
|
||||
func NewPublishJobServiceWithConfig(
|
||||
pool *pgxpool.Pool,
|
||||
messaging *rabbitmq.Client,
|
||||
redis *goredis.Client,
|
||||
logger *zap.Logger,
|
||||
cfg config.Provider,
|
||||
) *PublishJobService {
|
||||
return &PublishJobService{
|
||||
pool: pool,
|
||||
messaging: messaging,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
pool: pool,
|
||||
messaging: messaging,
|
||||
redis: redis,
|
||||
logger: logger,
|
||||
compliance: tenantcompliance.NewService(pool, cfg, logger).WithRabbitMQ(messaging),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,10 +70,12 @@ type CreatePublishJobAccountRequest struct {
|
||||
}
|
||||
|
||||
type CreatePublishJobRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
ContentRef map[string]any `json:"content_ref" binding:"required"`
|
||||
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
ContentRef map[string]any `json:"content_ref" binding:"required"`
|
||||
Accounts []CreatePublishJobAccountRequest `json:"accounts" binding:"required,min=1"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
ArticleVersionID *int64 `json:"article_version_id,omitempty"`
|
||||
AckRecordID *int64 `json:"ack_record_id,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePublishJobResponse struct {
|
||||
@@ -110,6 +130,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
targetClientID uuid.UUID
|
||||
}
|
||||
targets := make([]publishTarget, 0, len(req.Accounts))
|
||||
targetPlatformSet := make(map[string]struct{})
|
||||
|
||||
for _, accountID := range requestedAccountIDs {
|
||||
account, lookupErr := accountRepo.GetByDesktopID(ctx, accountID, actor.PrimaryWorkspaceID)
|
||||
@@ -141,17 +162,51 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
accountSeed: accountSeed,
|
||||
targetClientID: *targetClientID,
|
||||
})
|
||||
if platformID := strings.TrimSpace(accountSeed.PlatformID); platformID != "" {
|
||||
targetPlatformSet[platformID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
effectiveVersionID, err := s.compliance.ResolvePublishableVersion(ctx, actor.TenantID, articleID, req.ArticleVersionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetPlatforms := make([]string, 0, len(targetPlatformSet))
|
||||
for platformID := range targetPlatformSet {
|
||||
targetPlatforms = append(targetPlatforms, platformID)
|
||||
}
|
||||
gate, err := s.compliance.GateForPublish(ctx, tenantcompliance.GateForPublishRequest{
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
ArticleVersionID: effectiveVersionID,
|
||||
ActorID: actor.UserID,
|
||||
TargetPlatforms: targetPlatforms,
|
||||
AckRecordID: req.AckRecordID,
|
||||
TriggerSource: "publish_gate",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, compliancePublishError(err, gate)
|
||||
}
|
||||
if gate != nil {
|
||||
switch gate.Decision {
|
||||
case sharedcompliance.GateDecisionBlock:
|
||||
return nil, compliancePublishError(response.ErrConflict(41001, "compliance_blocked", "content compliance blocked this publish request"), gate)
|
||||
case sharedcompliance.GateDecisionNeedsAck:
|
||||
return nil, compliancePublishError(response.ErrConflict(41002, "compliance_needs_ack", "content compliance requires acknowledgement before publishing"), gate)
|
||||
}
|
||||
}
|
||||
|
||||
jobID := uuid.New()
|
||||
job, err := taskRepo.CreatePublishJob(ctx, repository.CreateDesktopPublishJobParams{
|
||||
DesktopID: jobID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
CreatedByUserID: actor.UserID,
|
||||
Title: req.Title,
|
||||
ContentRef: contentRefJSON,
|
||||
ScheduledAt: req.ScheduledAt,
|
||||
DesktopID: jobID,
|
||||
TenantID: actor.TenantID,
|
||||
WorkspaceID: actor.PrimaryWorkspaceID,
|
||||
CreatedByUserID: actor.UserID,
|
||||
Title: req.Title,
|
||||
ContentRef: contentRefJSON,
|
||||
ScheduledAt: req.ScheduledAt,
|
||||
ArticleID: &articleID,
|
||||
ArticleVersionID: &effectiveVersionID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, response.ErrInternal(50097, "desktop_publish_job_create_failed", "failed to create desktop publish job")
|
||||
@@ -189,6 +244,7 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
"account_id": target.account.DesktopID.String(),
|
||||
"platform": target.account.Platform,
|
||||
"article_id": articleID,
|
||||
"article_version_id": effectiveVersionID,
|
||||
"publish_batch_id": publishBatchID,
|
||||
"publish_record_id": publishRecordID,
|
||||
"platform_account_id": target.accountSeed.ID,
|
||||
@@ -217,6 +273,27 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
taskIDs = append(taskIDs, task.DesktopID.String())
|
||||
}
|
||||
|
||||
if req.AckRecordID != nil {
|
||||
if gate == nil || gate.Result == nil {
|
||||
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record cannot be consumed without a matching compliance result")
|
||||
}
|
||||
consumed, consumeErr := s.compliance.ConsumeAckTx(ctx, tx, *req.AckRecordID, tenantcompliance.ConsumeAckQuery{
|
||||
TenantID: actor.TenantID,
|
||||
ArticleID: articleID,
|
||||
ArticleVersionID: effectiveVersionID,
|
||||
CheckRecordID: gate.Result.RecordID,
|
||||
ContentHash: gate.Result.ContentHash,
|
||||
PolicyFingerprint: gate.Result.PolicyFingerprint,
|
||||
ConsumedByJobID: jobID,
|
||||
})
|
||||
if consumeErr != nil {
|
||||
return nil, response.ErrInternal(51004, "compliance_ack_lookup_failed", "failed to consume compliance ack")
|
||||
}
|
||||
if !consumed {
|
||||
return nil, response.ErrBadRequest(41003, "compliance_ack_mismatch", "ack record is expired, consumed, or no longer matches this publish request")
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, response.ErrInternal(50100, "desktop_publish_job_commit_failed", "failed to commit desktop publish job")
|
||||
}
|
||||
@@ -267,6 +344,20 @@ func (s *PublishJobService) Create(ctx context.Context, actor auth.Actor, req Cr
|
||||
}, nil
|
||||
}
|
||||
|
||||
func compliancePublishError(err error, gate *tenantcompliance.GateOutcome) error {
|
||||
appErr := response.Normalize(err)
|
||||
if gate == nil || gate.Result == nil {
|
||||
return appErr
|
||||
}
|
||||
detail, marshalErr := json.Marshal(gate.Result)
|
||||
if marshalErr != nil {
|
||||
return appErr
|
||||
}
|
||||
copyErr := *appErr
|
||||
copyErr.Detail = string(detail)
|
||||
return ©Err
|
||||
}
|
||||
|
||||
func (s *PublishJobService) RetryByDesktopTask(
|
||||
ctx context.Context,
|
||||
client *repository.DesktopClient,
|
||||
|
||||
Reference in New Issue
Block a user