Repository navigation
Expand file tree
/
Copy pathapp.go
More file actions
312 lines (267 loc) · 10.5 KB
/
Copy pathapp.go
File metadata and controls
312 lines (267 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
// Package documentstore is the eSignature-Portal document-store service: the
// platform's single source of truth for document BYTES and HASHES. It owns
// ingest, the canonical SHA-256, envelope-encrypted S3 storage with a 24h TTL,
// ASiC-E assembly/completion via the embedded gmb-lib/go-asice library (integrity
// self-checked locally with asice.CheckReferences — no DSS call), and retention.
// It is a pure byte/hash supplier: the user-facing DSS validate +
// archive-timestamp are owned by the Signing Orchestrator, which fetches bytes
// from here. Metadata lives in the `document` schema, reached only through
// SECURITY DEFINER procedures under the EXECUTE-only document_public role.
//
// All cross-cutting concerns (logging + redaction, OpenTelemetry tracing,
// correlation) come from go-platform-kit's platform.Setup — never wired
// per-service.
package documentstore
import (
"fmt"
"strings"
"azugo.io/azugo"
"azugo.io/azugo/server"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/gmb-lib/go-authbyte/authclient"
"github.com/gmb-lib/go-authbyte/declare"
"github.com/gmb-lib/go-authbyte/permissions"
"github.com/gmb-lib/go-gdpr-audit/gdpr"
"github.com/gmb-lib/go-platform-kit/broker"
"github.com/gmb-lib/go-platform-kit/broker/natsbroker"
"github.com/gmb-lib/go-platform-kit/platform"
"github.com/gmb-lib/go-sec-events/secevents"
"github.com/signbyte/document-store/audit"
"github.com/signbyte/document-store/documents"
"github.com/signbyte/document-store/kms"
"github.com/signbyte/document-store/s3"
"github.com/signbyte/document-store/store"
"github.com/signbyte/document-store/tasks"
)
// App is the document-store application container.
type App struct {
*azugo.App
config *Configuration
store store.Store
blob s3.Store
kms kms.KMS
docs *documents.Service
authClient *authclient.Client
authMW azugo.RequestHandlerFunc
// Outbound (set only when GDPR-audit / access-audit or a membership register
// is configured).
outboundClient *authclient.Client
gdprAudit *gdpr.Client
// declaration is the task that declares the service to the membership register;
// nil unless MEMBERSHIP_URL is set.
declaration *declare.Task
audit *audit.Recorder
}
// New constructs the application.
func New(cmd *cobra.Command, version string) (*App, error) {
config := NewConfiguration()
a, err := server.New(cmd, server.Options{
AppName: "Document Store Service",
AppVer: version,
Configuration: config,
})
if err != nil {
return nil, err
}
app := &App{App: a, config: config}
if err := app.init(); err != nil {
return nil, err
}
return app, nil
}
func (a *App) init() error {
cfg := a.config
// Platform glue FIRST: logging + redaction, OpenTelemetry tracing, correlation.
if err := platform.Setup(a.App, platform.Options{Config: cfg.BaseConfiguration}); err != nil {
return err
}
// Metadata store: `document` schema via the EXECUTE-only role, or in-memory (dev).
var err error
switch cfg.StoreBackend() {
case StoreBackendPostgres:
a.store, err = store.NewPostgres(a.BackgroundContext(), cfg.StoreDSN)
if err != nil {
return err
}
default:
a.Log().Warn("no store DSN configured (DOCUMENT_STORE_DSN) — using in-memory metadata store; documents will NOT survive restarts (development only)")
a.store = store.NewMemory()
}
// Encrypted byte store: S3-API object storage, or in-memory (dev).
switch cfg.BlobBackend() {
case BlobBackendS3:
a.blob, err = s3.New(s3.Options{
Endpoint: cfg.S3Endpoint,
AccessKey: cfg.S3AccessKey,
SecretKey: cfg.S3SecretKey,
UseSSL: cfg.S3UseSSL,
Bucket: cfg.S3Bucket,
Prefix: cfg.S3Prefix,
})
if err != nil {
return err
}
default:
a.Log().Warn("no S3 configured (S3_ENDPOINT/S3_BUCKET) — using in-memory blob store; bytes will NOT survive restarts (development only)")
a.blob = s3.NewMemory()
}
// KMS envelope encryption (local provider; swap for Vault/AWS in prod).
master, err := cfg.MasterKeyBytes()
if err != nil {
return fmt.Errorf("document-store: invalid DOCUMENT_KMS_MASTER_KEY (expect base64 32 bytes): %w", err)
}
localKMS, ephemeral, err := kms.NewLocal(master)
if err != nil {
return fmt.Errorf("document-store: kms: %w", err)
}
if ephemeral {
a.Log().Warn("no DOCUMENT_KMS_MASTER_KEY configured — generated an EPHEMERAL master key; stored bytes become undecryptable after a restart (development only)")
}
a.kms = localKMS
a.docs = documents.New(a.store, a.blob, a.kms, cfg.RetentionTTL)
// Inbound service authentication (go-authbyte DPoP): callers present
// svc:document service tokens (the delegated on-behalf tokens included).
a.authClient, err = authclient.New(cfg.Auth)
if err != nil {
return fmt.Errorf("document-store: auth client: %w", err)
}
a.authMW = a.authClient.Authenticate()
// Outbound DPoP service client — serves the access-audit Poster (aud
// svc:access-audit) and the declaration to the membership register. One client
// serves both; it exists only when either is configured. document-store does
// NOT call eparaksts-signer: the user-facing DSS validate/archive is the
// Signing Orchestrator's call.
if cfg.OutboundEnabled() {
a.outboundClient, err = authclient.New(cfg.OutboundAuthClientConfig())
if err != nil {
return fmt.Errorf("document-store: outbound auth client: %w", err)
}
}
// NIS2-audit emitter (always) + GDPR-audit client (optional) + domain-event
// publisher (broker when BROKER_URL is set, else the dev log transport).
// Built with the service logger: the retention sweep emits with no request whose
// logger it could borrow.
secEmitter := secevents.NewEmitter(secevents.NewLogSinkFor(a.Log()))
var gc *gdpr.Client
if cfg.AccessAuditEnabled() {
var outbox gdpr.Outbox
if dir := cfg.AccessAuditOutboxDir; dir != "" {
ob, err := gdpr.NewFileOutbox(dir, gdpr.DefaultOutboxCapacity)
if err != nil {
return fmt.Errorf("document-store: audit outbox: %w", err)
}
outbox = ob
}
gc, err = gdpr.New(
cfg.GDPRConfig(),
newAccessAuditPoster(a.outboundClient, cfg.AccessAuditURL, cfg.AccessAuditAudience, cfg.AccessAuditScope),
gdpr.Options{Outbox: outbox, Logger: a.Log()},
)
if err != nil {
return fmt.Errorf("document-store: gdpr-audit client: %w", err)
}
a.gdprAudit = gc
if err := a.AddTask(audit.NewDrainTask(gc)); err != nil {
return fmt.Errorf("document-store: gdpr drain task: %w", err)
}
} else {
a.Log().Warn("ACCESS_AUDIT_URL not set — GDPR (GDPR-audit) access records will NOT be posted (development); NIS2-audit security telemetry still emits")
}
var domainPub *broker.Publisher
var domainTransport broker.Transport
if cfg.Broker != nil && cfg.Broker.URL != "" {
conn, err := natsbroker.Connect(natsbroker.Config{
URL: cfg.Broker.URL,
TLSCert: cfg.Broker.TLSCert,
TLSKey: cfg.Broker.TLSKey,
TLSCA: cfg.Broker.TLSCA,
Name: cfg.ServiceName,
})
if err != nil {
return fmt.Errorf("document-store: broker connect: %w", err)
}
domainTransport = natsbroker.NewTransport(conn)
a.Log().Info("document domain events → NATS JetStream",
zap.String("broker_url", cfg.Broker.URL), zap.String("topic", cfg.DocumentEventsTopic))
} else {
domainTransport = newLogTransport(a.Log())
a.Log().Warn("BROKER_URL unset — document domain events go to the dev log transport only (the Signing Orchestrator's eIDAS-audit feed is not durable); set BROKER_URL to publish")
}
domainPub = broker.NewPublisher(domainTransport, cfg.ServiceName)
a.audit = audit.New(secEmitter, gc, domainPub, cfg.DocumentEventsTopic, a.Log())
// Retention sweep (24h TTL): destroys expired non-hold bytes + flips status.
return a.AddTask(tasks.NewRetentionTask(tasks.RetentionConfig{
Service: a.docs,
Audit: a.audit,
Interval: cfg.RetentionSweepInterval,
Batch: cfg.RetentionSweepBatch,
HistoryKeep: cfg.HistoryRetention,
Logger: a.Log(),
}))
}
// DeclareTo adds the task that declares sets to the membership register at start,
// as the service itself. With MEMBERSHIP_URL unset it does nothing at all. The
// task sends in the background and never delays or stops the start: a register
// that is away or refuses is logged and tried again. It is called where the sets
// are known (the routes package, which checks the levels the sets declare).
func (a *App) DeclareTo(sets ...*permissions.Set) error {
cfg := a.config
if !cfg.MembershipEnabled() {
return nil
}
log := a.Log().Named("declaration")
keys := strings.Join(permissions.ServiceKeys(sets...), ", ")
task, err := declare.New(a.outboundClient, cfg.MembershipURL, cfg.MembershipAudience, declare.Config{
OnError: func(err error) {
log.Error("declaring the service to the membership register failed", zap.Error(err))
},
OnDeclared: func([]byte) {
log.Info("the membership register accepted the declaration", zap.String("service", keys))
},
}, sets...)
if err != nil {
return fmt.Errorf("document-store: declaration: %w", err)
}
if err := a.AddTask(task); err != nil {
return fmt.Errorf("document-store: declaration task: %w", err)
}
a.declaration = task
return nil
}
// Start verifies backend connectivity (non-fatal) then starts the server + tasks.
func (a *App) Start() error {
if err := a.store.Ping(a.BackgroundContext()); err != nil {
a.Log().Warn("document store not reachable at start — readiness will report not-ready until it recovers", zap.Error(err))
}
if err := a.blob.Ping(a.BackgroundContext()); err != nil {
a.Log().Warn("blob store not reachable at start — readiness will report not-ready until it recovers", zap.Error(err))
}
return a.App.Start()
}
// Stop releases backend resources, then stops the server.
func (a *App) Stop() {
if a.store != nil {
a.store.Close()
}
a.App.Stop()
}
// Config returns the loaded configuration.
func (a *App) Config() *Configuration {
if a.config == nil || !a.config.Ready() {
panic("configuration is not loaded")
}
return a.config
}
// Documents returns the document domain service.
func (a *App) Documents() *documents.Service { return a.docs }
// Store returns the metadata store (readiness checks).
func (a *App) Store() store.Store { return a.store }
// Blob returns the byte store (readiness checks).
func (a *App) Blob() s3.Store { return a.blob }
// Audit returns the audit recorder.
func (a *App) Audit() *audit.Recorder { return a.audit }
// AuthMiddleware returns the inbound service-authentication middleware.
func (a *App) AuthMiddleware() azugo.RequestHandlerFunc { return a.authMW }
// SetAuthMiddleware overrides the inbound auth middleware (test use only).
func (a *App) SetAuthMiddleware(mw azugo.RequestHandlerFunc) { a.authMW = mw }