| 1 | package operator |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "database/sql" |
| 7 | "errors" |
| 8 | "net/url" |
| 9 | |
| 10 | "amadan.net/oficina/home/internal/home/directorydata" |
| 11 | ) |
| 12 | |
| 13 | type VerificationInput struct { |
| 14 | InputBase |
| 15 | Consumer string `json:"consumer"` |
| 16 | TeamSQID string `json:"team_sqid"` |
| 17 | App string `json:"app"` |
| 18 | Audience string `json:"audience"` |
| 19 | Generation string `json:"generation"` |
| 20 | RecordSequence int64 `json:"record_sequence"` |
| 21 | RecordDigest string `json:"record_digest"` |
| 22 | Epoch int64 `json:"expected_epoch"` |
| 23 | Owner string `json:"owner_ref"` |
| 24 | Issued int64 `json:"issued_at"` |
| 25 | Expires int64 `json:"expires_at"` |
| 26 | } |
| 27 | type ActivationEvidence struct { |
| 28 | Version int `json:"version"` |
| 29 | Policy string `json:"policy"` |
| 30 | Attempt string `json:"attempt_id"` |
| 31 | Consumer string `json:"consumer"` |
| 32 | TeamSQID string `json:"team_sqid"` |
| 33 | App string `json:"app"` |
| 34 | Audience string `json:"audience"` |
| 35 | Generation string `json:"generation"` |
| 36 | RecordSequence int64 `json:"record_sequence"` |
| 37 | RecordDigest string `json:"record_digest"` |
| 38 | Epoch int64 `json:"epoch"` |
| 39 | Owner string `json:"owner_ref"` |
| 40 | AppVersion string `json:"app_version"` |
| 41 | ArtifactDigest string `json:"artifact_digest"` |
| 42 | EnvironmentBinding string `json:"environment_binding"` |
| 43 | ManifestDigest string `json:"credential_manifest_digest"` |
| 44 | AliasHost string `json:"alias_host"` |
| 45 | Started int64 `json:"started_at"` |
| 46 | Completed int64 `json:"completed_at"` |
| 47 | } |
| 48 | type ActivationInput struct { |
| 49 | InputBase |
| 50 | Evidence ActivationEvidence `json:"evidence"` |
| 51 | } |
| 52 | |
| 53 | type pendingBinding struct { |
| 54 | teamID, epoch int64 |
| 55 | label string |
| 56 | sequence int64 |
| 57 | generation string |
| 58 | digest []byte |
| 59 | } |
| 60 | |
| 61 | func (d *Directory) pending(ctx context.Context, c *sql.Conn, consumer, team, app, audience, gen string, sequence int64, recordDigest, owner string, epoch int64) (pendingBinding, error) { |
| 62 | fail := func() (pendingBinding, error) { return pendingBinding{}, ErrRefused } |
| 63 | expected, ok := canonical(recordDigest, 32) |
| 64 | if !atom(consumer, 64) || !d.apps[app] || !Origin(audience) || !CanonicalToken(gen) || sequence <= 0 || !ok || !PersonRef(owner) || epoch <= 0 { |
| 65 | return fail() |
| 66 | } |
| 67 | var b pendingBinding |
| 68 | var storedTeam, storedApp, storedOrigin, state string |
| 69 | var enabled int |
| 70 | err := c.QueryRowContext(ctx, `SELECT t.id,t.sqid,t.label,t.roster_epoch,i.app,i.origin,i.state,c.enabled |
| 71 | FROM roster_consumers c JOIN team_instances i ON i.consumer_id=c.consumer_id AND i.team_id=c.team_id AND i.app=c.app AND i.origin=c.origin |
| 72 | JOIN teams t ON t.id=c.team_id WHERE c.consumer_id=?`, consumer).Scan(&b.teamID, &storedTeam, &b.label, &b.epoch, &storedApp, &storedOrigin, &state, &enabled) |
| 73 | if err != nil { |
| 74 | if errors.Is(err, sql.ErrNoRows) { |
| 75 | return fail() |
| 76 | } |
| 77 | return pendingBinding{}, ErrUnavailable |
| 78 | } |
| 79 | if enabled != 1 || state != "pending" || storedTeam != team || storedApp != app || storedOrigin != audience || b.epoch != epoch { |
| 80 | return fail() |
| 81 | } |
| 82 | if err = generation(ctx, c, gen); err != nil { |
| 83 | return pendingBinding{}, err |
| 84 | } |
| 85 | var receiptSequence int64 |
| 86 | var receiptGeneration, receiptConsumer, kind string |
| 87 | var receiptDigest []byte |
| 88 | err = c.QueryRowContext(ctx, `SELECT p.sequence,p.generation,p.record_digest,r.sequence,r.generation,r.record_digest,r.consumer_id,r.operation |
| 89 | FROM directory_operator_consumer_records p JOIN roster_operator_receipts r ON r.operation_id=p.operation_id WHERE p.consumer_id=?`, consumer).Scan(&b.sequence, &b.generation, &b.digest, &receiptSequence, &receiptGeneration, &receiptDigest, &receiptConsumer, &kind) |
| 90 | if err != nil { |
| 91 | return pendingBinding{}, ErrUnavailable |
| 92 | } |
| 93 | if b.sequence != sequence || b.generation != gen || !bytes.Equal(b.digest, expected) || receiptSequence != b.sequence || receiptGeneration != b.generation || !bytes.Equal(receiptDigest, b.digest) || receiptConsumer != consumer || kind != "provision" { |
| 94 | return fail() |
| 95 | } |
| 96 | var found int |
| 97 | if err = c.QueryRowContext(ctx, "SELECT 1 FROM memberships WHERE team_id=? AND person_ref=? AND role='owner'", b.teamID, owner).Scan(&found); err != nil { |
| 98 | if errors.Is(err, sql.ErrNoRows) { |
| 99 | return fail() |
| 100 | } |
| 101 | return pendingBinding{}, ErrUnavailable |
| 102 | } |
| 103 | var currentTokens int |
| 104 | if err = c.QueryRowContext(ctx, "SELECT count(*) FROM roster_tokens WHERE consumer_id=? AND slot='current' AND expires_at IS NULL AND issued_at<=?", consumer, d.engine.now().Unix()).Scan(¤tTokens); err != nil { |
| 105 | return pendingBinding{}, ErrUnavailable |
| 106 | } |
| 107 | if currentTokens != 1 { |
| 108 | return fail() |
| 109 | } |
| 110 | if !d.policy.ValidateRoute(directoryRoute(b, team, app, audience, consumer)) { |
| 111 | return fail() |
| 112 | } |
| 113 | return b, nil |
| 114 | } |
| 115 | func (d *Directory) verifyConsumer(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { |
| 116 | var in VerificationInput |
| 117 | if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial { |
| 118 | return nil, ErrRefused |
| 119 | } |
| 120 | now := d.engine.now().Unix() |
| 121 | if in.Issued <= 0 || in.Issued > now || in.Expires <= now || in.Expires <= in.Issued || in.Expires-in.Issued > 600 { |
| 122 | return nil, ErrRefused |
| 123 | } |
| 124 | binding, e := d.pending(ctx, c, in.Consumer, in.TeamSQID, in.App, in.Audience, in.Generation, in.RecordSequence, in.RecordDigest, in.Owner, in.Epoch) |
| 125 | if e != nil { |
| 126 | return nil, e |
| 127 | } |
| 128 | var old, oldConsumer string |
| 129 | var issued, expires int64 |
| 130 | e = c.QueryRowContext(ctx, "SELECT a.operation_id,a.consumer_id,a.issued_at,a.expires_at FROM verification_active v JOIN verification_attempts a ON a.operation_id=v.operation_id WHERE v.consumer_id=?", in.Consumer).Scan(&old, &oldConsumer, &issued, &expires) |
| 131 | if e == nil { |
| 132 | if oldConsumer != in.Consumer || issued <= 0 || issued > now || expires <= issued || expires-issued > 600 || expires > now { |
| 133 | return nil, ErrRefused |
| 134 | } |
| 135 | if _, e = c.ExecContext(ctx, "DELETE FROM verification_active WHERE consumer_id=? AND operation_id=?", in.Consumer, old); e != nil { |
| 136 | return nil, e |
| 137 | } |
| 138 | if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'expired',?)", old, r.authorization.OperationID, now); e != nil { |
| 139 | return nil, e |
| 140 | } |
| 141 | } else if !errors.Is(e, sql.ErrNoRows) { |
| 142 | return nil, ErrUnavailable |
| 143 | } |
| 144 | hash, ok := canonical(r.Head(), 32) |
| 145 | if !ok { |
| 146 | return nil, ErrUnavailable |
| 147 | } |
| 148 | _, e = c.ExecContext(ctx, "INSERT INTO verification_attempts VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", r.authorization.OperationID, in.Consumer, binding.teamID, in.App, in.Audience, in.Generation, in.RecordSequence, binding.digest, in.Epoch, in.Owner, in.Issued, in.Expires, hash) |
| 149 | if e != nil { |
| 150 | return nil, e |
| 151 | } |
| 152 | if _, e = c.ExecContext(ctx, "INSERT INTO verification_active VALUES(?,?)", in.Consumer, r.authorization.OperationID); e != nil { |
| 153 | return nil, e |
| 154 | } |
| 155 | if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'opened',?)", r.authorization.OperationID, r.authorization.OperationID, now); e != nil { |
| 156 | return nil, e |
| 157 | } |
| 158 | result := resultFor(r) |
| 159 | result.Attempt = r.authorization.OperationID |
| 160 | result.Expires = in.Expires |
| 161 | return encode(result) |
| 162 | } |
| 163 | func version(s string) bool { |
| 164 | if len(s) != 40 { |
| 165 | return false |
| 166 | } |
| 167 | for _, c := range s { |
| 168 | if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') { |
| 169 | return false |
| 170 | } |
| 171 | } |
| 172 | return true |
| 173 | } |
| 174 | func (d *Directory) activate(ctx context.Context, c *sql.Conn, r Request) ([]byte, error) { |
| 175 | var in ActivationInput |
| 176 | if DecodeInput(r, &in) != nil || !in.matches(r) || r.authorization.Initial { |
| 177 | return nil, ErrRefused |
| 178 | } |
| 179 | p := in.Evidence |
| 180 | now := d.engine.now().Unix() |
| 181 | u, e := url.Parse(p.Audience) |
| 182 | if e != nil || p.Version != 1 || p.Policy != "oficina/team-activation/v1" || !CanonicalToken(p.Attempt) || !version(p.AppVersion) || !CanonicalToken(p.ArtifactDigest) || !CanonicalToken(p.EnvironmentBinding) || !CanonicalToken(p.ManifestDigest) || p.AliasHost != u.Host || p.Started <= 0 || p.Completed < p.Started || p.Completed > now || now-p.Completed >= 300 { |
| 183 | return nil, ErrRefused |
| 184 | } |
| 185 | binding, e := d.pending(ctx, c, p.Consumer, p.TeamSQID, p.App, p.Audience, p.Generation, p.RecordSequence, p.RecordDigest, p.Owner, p.Epoch) |
| 186 | if e != nil { |
| 187 | return nil, e |
| 188 | } |
| 189 | var consumer, app, origin, gen, owner string |
| 190 | var teamID, sequence, epoch, issued, expires int64 |
| 191 | var recordDigest []byte |
| 192 | e = c.QueryRowContext(ctx, `SELECT a.consumer_id,a.team_id,a.app,a.origin,a.generation,a.record_sequence,a.record_digest,a.expected_epoch,a.owner_ref,a.issued_at,a.expires_at |
| 193 | FROM verification_active v JOIN verification_attempts a ON a.operation_id=v.operation_id WHERE v.consumer_id=? AND a.operation_id=?`, p.Consumer, p.Attempt).Scan(&consumer, &teamID, &app, &origin, &gen, &sequence, &recordDigest, &epoch, &owner, &issued, &expires) |
| 194 | if e != nil { |
| 195 | if errors.Is(e, sql.ErrNoRows) { |
| 196 | return nil, ErrRefused |
| 197 | } |
| 198 | return nil, ErrUnavailable |
| 199 | } |
| 200 | if consumer != p.Consumer || teamID != binding.teamID || app != p.App || origin != p.Audience || gen != p.Generation || sequence != p.RecordSequence || !bytes.Equal(recordDigest, binding.digest) || epoch != p.Epoch || owner != p.Owner || issued <= 0 || issued > now || expires <= now || expires <= issued || expires-issued > 600 || p.Started < issued || p.Completed >= expires { |
| 201 | return nil, ErrRefused |
| 202 | } |
| 203 | if _, e = c.ExecContext(ctx, "UPDATE team_instances SET state='live' WHERE consumer_id=? AND state='pending'", p.Consumer); e != nil { |
| 204 | return nil, e |
| 205 | } |
| 206 | if _, e = c.ExecContext(ctx, "DELETE FROM verification_active WHERE consumer_id=? AND operation_id=?", p.Consumer, p.Attempt); e != nil { |
| 207 | return nil, e |
| 208 | } |
| 209 | if _, e = c.ExecContext(ctx, "INSERT INTO verification_outcomes(attempt_id,operator_operation_id,kind,completed_at) VALUES(?,?,'activated',?)", p.Attempt, r.authorization.OperationID, now); e != nil { |
| 210 | return nil, e |
| 211 | } |
| 212 | result := resultFor(r) |
| 213 | result.Attempt = p.Attempt |
| 214 | result.Generation = p.Generation |
| 215 | result.TeamID = binding.teamID |
| 216 | result.TeamSQID = p.TeamSQID |
| 217 | result.Epoch = p.Epoch |
| 218 | return encode(result) |
| 219 | } |
| 220 | |
| 221 | func directoryRoute(b pendingBinding, team, app, audience, consumer string) directorydata.Route { |
| 222 | return directorydata.Route{TeamID: b.teamID, TeamSQID: team, Label: b.label, App: app, Origin: audience, ConsumerID: consumer} |
| 223 | } |
| 224 | |