-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpostgres.go
More file actions
225 lines (195 loc) · 7.51 KB
/
Copy pathpostgres.go
File metadata and controls
225 lines (195 loc) · 7.51 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
// Package store is the identity-mapping store (PostgreSQL). It maps the
// upstream IdP subject to a stable internal subject and keeps a minimal
// profile.
//
// All access goes through SECURITY DEFINER stored procedures (the uniform
// `(pi_data jsonb, INOUT po_data jsonb)` envelope); the service connects with an
// EXECUTE-only role (`authbyte_public`) that has no direct table access. The schema
// and procedures are owned by the separate authbyte-db repo and applied with
// Evolve migrations — this package only CALLs them.
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store is the PostgreSQL-backed identity mapping store.
type Store struct {
pool *pgxpool.Pool
}
// New opens a connection pool to PostgreSQL.
func New(ctx context.Context, dsn string) (*Store, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("store: connect: %w", err)
}
return &Store{pool: pool}, nil
}
// Close releases the pool.
func (s *Store) Close() {
s.pool.Close()
}
// Ping verifies connectivity.
func (s *Store) Ping(ctx context.Context) error {
if err := s.pool.Ping(ctx); err != nil {
return fmt.Errorf("store: ping: %w", err)
}
return nil
}
// Profile is the minimal profile stored alongside the mapping. SerialNumber is
// the eIDAS national id in its canonical spelling (e.g. PNOLV-XXXXXXXXXXX) — the
// key the person is deduped on across auth methods. LoginMethod records which
// auth method produced this credential (eparakstsMobile, webEid, …) and is
// stored on the credential row.
type Profile struct {
Name string
GivenName string
FamilyName string
SerialNumber string
LoginMethod string
}
// Mapping is an identity row as returned by identity.get.
type Mapping struct {
InternalSub string `json:"internal_sub"`
IdPSub string `json:"idp_sub"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
SerialNumber string `json:"serial_number"`
}
// ProcedureError is a procedure's own refusal: the `<domain>:<reason>` code and
// the message it answered with, whether it returned the error or raised it after
// a write. A caller that can act on a particular refusal (a validation the caller
// wants to answer as such, rather than as an outage) reads Code; everything else
// treats it as an error like any other.
type ProcedureError struct {
Procedure string
Code string
Message string
}
func (e *ProcedureError) Error() string {
return fmt.Sprintf("store: %s: %s: %s", e.Procedure, e.Code, e.Message)
}
// envelope is the structured result returned by every procedure
// (util.result_success / util.result_error).
type envelope struct {
Result string `json:"result"`
Data json.RawMessage `json:"data"`
Code string `json:"code"`
Message string `json:"message"`
}
// call invokes a SECURITY DEFINER procedure with the uniform JSONB envelope and
// returns the decoded `data` payload, or a typed error built from the
// procedure's result_error code/message.
func (s *Store) call(ctx context.Context, proc string, in any) (json.RawMessage, error) {
inJSON, err := json.Marshal(in)
if err != nil {
return nil, fmt.Errorf("store: marshal input: %w", err)
}
// CALL with an INOUT parameter returns a single-column result row carrying
// the procedure's po_data; NULL seeds the INOUT slot.
q := fmt.Sprintf("CALL %s($1::jsonb, NULL::jsonb)", proc)
var out []byte
if err := s.pool.QueryRow(ctx, q, inJSON).Scan(&out); err != nil {
// A procedure that fails after a write re-raises a structured error with
// SQLSTATE P0001 (Pattern B) to force a rollback; its message is the
// util.result_error JSON. Recover the code/message so callers see the
// same shape as the validation (returned-error) path.
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "P0001" {
var env envelope
if json.Unmarshal([]byte(pgErr.Message), &env) == nil && env.Result == "error" {
return nil, &ProcedureError{Procedure: proc, Code: env.Code, Message: env.Message}
}
}
return nil, fmt.Errorf("store: %s: %w", proc, err)
}
var env envelope
if err := json.Unmarshal(out, &env); err != nil {
return nil, fmt.Errorf("store: %s: decode result: %w", proc, err)
}
if env.Result != "success" {
return nil, &ProcedureError{Procedure: proc, Code: env.Code, Message: env.Message}
}
return env.Data, nil
}
// EnsureMapping resolves a login to the stable PERSON subject and links the
// auth-method credential. The person is keyed on the eIDAS national id
// (p.SerialNumber), so the SAME human authenticating through different methods
// (eParaksts Mobile vs Web eID card) — which present DIFFERENT idpSubject values —
// resolves to ONE internal subject instead of one identity per method. The
// returned bool is true only when the PERSON was created on this call (their
// first-ever login, by any method) so the caller records the correct GDPR-audit
// event (identity created vs updated); adding a new method to a known person is an
// update. Implemented via the identity.upsert procedure (no direct table access).
func (s *Store) EnsureMapping(ctx context.Context, idpSubject string, p Profile) (string, bool, error) {
data, err := s.call(ctx, "identity.upsert", map[string]any{
"idp_sub": idpSubject,
"name": p.Name,
"given_name": p.GivenName,
"family_name": p.FamilyName,
"serial_number": p.SerialNumber, // the national id — the person key
"login_method": p.LoginMethod,
})
if err != nil {
return "", false, err
}
var res struct {
InternalSub string `json:"internal_sub"`
Created bool `json:"created"`
}
if err := json.Unmarshal(data, &res); err != nil {
return "", false, fmt.Errorf("store: ensure mapping: decode: %w", err)
}
return res.InternalSub, res.Created, nil
}
// Registration is a person an administrator registers before their first login:
// the canonical identity code and whatever name is known. No credential.
type Registration struct {
IdentityCode string
Name string
GivenName string
FamilyName string
}
// Register creates the person row for somebody who has not logged in yet — or
// answers the existing person's subject when the code is already known — via
// the identity.register procedure. The returned bool is true only when THIS call
// created the person. The procedure refuses a non-canonical code with a
// ProcedureError whose Code is "identity:invalid".
func (s *Store) Register(ctx context.Context, reg Registration) (string, bool, error) {
data, err := s.call(ctx, "identity.register", map[string]any{
"national_id": reg.IdentityCode,
"name": reg.Name,
"given_name": reg.GivenName,
"family_name": reg.FamilyName,
})
if err != nil {
return "", false, err
}
var res struct {
PersonSub string `json:"person_sub"`
Created bool `json:"created"`
}
if err := json.Unmarshal(data, &res); err != nil {
return "", false, fmt.Errorf("store: register: decode: %w", err)
}
return res.PersonSub, res.Created, nil
}
// Get reads an identity by internal subject (via the identity.get procedure).
func (s *Store) Get(ctx context.Context, internalSubject string) (Mapping, error) {
var m Mapping
data, err := s.call(ctx, "identity.get", map[string]any{
"internal_sub": internalSubject,
})
if err != nil {
return m, err
}
if err := json.Unmarshal(data, &m); err != nil {
return m, fmt.Errorf("store: get: decode: %w", err)
}
return m, nil
}