docs: reorder & testing
This commit is contained in:
+128
-29
@@ -332,15 +332,14 @@ func (s *Store) LoadSession(ctx context.Context, deviceSessionID uuid.UUID) (Ses
|
||||
return modelToSession(row), nil
|
||||
}
|
||||
|
||||
// RevokeSession transitions an active row to status='revoked' and
|
||||
// returns the row as it stands after the update. The boolean reports
|
||||
// whether the UPDATE actually changed a row — false means the row was
|
||||
// already revoked or did not exist; the auth Service then falls back to
|
||||
// LoadSession for idempotent-revoke responses.
|
||||
func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (Session, bool, error) {
|
||||
// TouchSessionLastSeen sets `last_seen_at` to at on the row keyed by
|
||||
// deviceSessionID. The UPDATE is gated by `status='active'` so a
|
||||
// revoked or absent row reports ErrSessionNotFound. Returns the post-
|
||||
// update row so the cache can be refreshed without a second read.
|
||||
func (s *Store) TouchSessionLastSeen(ctx context.Context, deviceSessionID uuid.UUID, at time.Time) (Session, error) {
|
||||
stmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.NOW()).
|
||||
UPDATE(table.DeviceSessions.LastSeenAt).
|
||||
SET(postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.DeviceSessions.DeviceSessionID.EQ(postgres.UUID(deviceSessionID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
@@ -350,39 +349,139 @@ func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID) (S
|
||||
var row model.DeviceSessions
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Session{}, false, nil
|
||||
return Session{}, ErrSessionNotFound
|
||||
}
|
||||
return Session{}, fmt.Errorf("auth store: touch last_seen %s: %w", deviceSessionID, err)
|
||||
}
|
||||
return modelToSession(row), nil
|
||||
}
|
||||
|
||||
// RevokeSession transitions an active row to status='revoked' and
|
||||
// inserts the matching audit row into session_revocations atomically
|
||||
// inside one transaction. The boolean reports whether the UPDATE
|
||||
// actually changed a row — false means the row was already revoked or
|
||||
// did not exist, in which case no audit row is written and the auth
|
||||
// Service falls back to LoadSession for the idempotent-revoke
|
||||
// response.
|
||||
func (s *Store) RevokeSession(ctx context.Context, deviceSessionID uuid.UUID, rc RevokeContext, at time.Time) (Session, bool, error) {
|
||||
var (
|
||||
revoked Session
|
||||
ok bool
|
||||
)
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
updateStmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.DeviceSessions.DeviceSessionID.EQ(postgres.UUID(deviceSessionID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
).
|
||||
RETURNING(sessionColumns())
|
||||
|
||||
var row model.DeviceSessions
|
||||
if err := updateStmt.QueryContext(ctx, tx, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
revoked = modelToSession(row)
|
||||
ok = true
|
||||
return insertRevocationTx(ctx, tx, deviceSessionID, revoked.UserID, rc, at)
|
||||
})
|
||||
if err != nil {
|
||||
return Session{}, false, fmt.Errorf("auth store: revoke session %s: %w", deviceSessionID, err)
|
||||
}
|
||||
return modelToSession(row), true, nil
|
||||
return revoked, ok, nil
|
||||
}
|
||||
|
||||
// RevokeAllForUser transitions every active row for userID to
|
||||
// status='revoked' and returns the rows as they stand after the update.
|
||||
// An empty slice with a nil error is returned when the user owned no
|
||||
// active sessions; the caller must treat that as a successful idempotent
|
||||
// revoke (the API surface returns revoked_count=0 in that case).
|
||||
func (s *Store) RevokeAllForUser(ctx context.Context, userID uuid.UUID) ([]Session, error) {
|
||||
stmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.NOW()).
|
||||
WHERE(
|
||||
table.DeviceSessions.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
).
|
||||
RETURNING(sessionColumns())
|
||||
// status='revoked', writes one session_revocations row per revoked
|
||||
// session, and returns the rows as they stand after the update. The
|
||||
// UPDATE and the audit inserts run inside one transaction. An empty
|
||||
// slice with a nil error is returned when the user owned no active
|
||||
// sessions; the caller treats that as a successful idempotent revoke
|
||||
// (the API surface returns revoked_count=0).
|
||||
func (s *Store) RevokeAllForUser(ctx context.Context, userID uuid.UUID, rc RevokeContext, at time.Time) ([]Session, error) {
|
||||
var out []Session
|
||||
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
updateStmt := table.DeviceSessions.
|
||||
UPDATE(table.DeviceSessions.Status, table.DeviceSessions.RevokedAt).
|
||||
SET(postgres.String(SessionStatusRevoked), postgres.TimestampzT(at)).
|
||||
WHERE(
|
||||
table.DeviceSessions.UserID.EQ(postgres.UUID(userID)).
|
||||
AND(table.DeviceSessions.Status.EQ(postgres.String(SessionStatusActive))),
|
||||
).
|
||||
RETURNING(sessionColumns())
|
||||
|
||||
var rows []model.DeviceSessions
|
||||
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
|
||||
var rows []model.DeviceSessions
|
||||
if err := updateStmt.QueryContext(ctx, tx, &rows); err != nil {
|
||||
return err
|
||||
}
|
||||
out = make([]Session, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
sess := modelToSession(row)
|
||||
out = append(out, sess)
|
||||
if err := insertRevocationTx(ctx, tx, sess.DeviceSessionID, sess.UserID, rc, at); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth store: revoke all for user %s: %w", userID, err)
|
||||
}
|
||||
out := make([]Session, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, modelToSession(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// insertRevocationTx writes a single audit row inside an existing
|
||||
// transaction. Callers are expected to mint a fresh revocation_id per
|
||||
// row; collisions are not retried because revocation_id is a uuid.New
|
||||
// in the only call sites.
|
||||
func insertRevocationTx(ctx context.Context, tx *sql.Tx, deviceSessionID, userID uuid.UUID, rc RevokeContext, at time.Time) error {
|
||||
actorUserID, actorUsername, err := revokeContextToColumns(rc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stmt := table.SessionRevocations.INSERT(
|
||||
table.SessionRevocations.RevocationID,
|
||||
table.SessionRevocations.DeviceSessionID,
|
||||
table.SessionRevocations.UserID,
|
||||
table.SessionRevocations.ActorKind,
|
||||
table.SessionRevocations.ActorUserID,
|
||||
table.SessionRevocations.ActorUsername,
|
||||
table.SessionRevocations.Reason,
|
||||
table.SessionRevocations.RevokedAt,
|
||||
).VALUES(uuid.New(), deviceSessionID, userID, string(rc.ActorKind), actorUserID, actorUsername, rc.Reason, at)
|
||||
|
||||
if _, err := stmt.ExecContext(ctx, tx); err != nil {
|
||||
return fmt.Errorf("insert session_revocations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revokeContextToColumns splits RevokeContext.ActorID into the
|
||||
// (actor_user_id, actor_username) pair persisted by session_revocations.
|
||||
// User-driven kinds parse ActorID as a UUID; admin-driven kinds keep it
|
||||
// as the operator username. Empty ActorID lands as NULL/NULL.
|
||||
func revokeContextToColumns(rc RevokeContext) (any, any, error) {
|
||||
if rc.ActorID == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
switch rc.ActorKind {
|
||||
case ActorKindUserSelf, ActorKindSoftDeleteUser:
|
||||
uid, err := uuid.Parse(rc.ActorID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("auth store: actor_id %q is not a uuid: %w", rc.ActorID, err)
|
||||
}
|
||||
return uid, nil, nil
|
||||
case ActorKindAdminSanction, ActorKindSoftDeleteAdmin:
|
||||
return nil, rc.ActorID, nil
|
||||
default:
|
||||
return nil, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// modelToChallenge projects a generated model row into the public
|
||||
// Challenge struct. Pointer fields are copied so callers cannot mutate
|
||||
// the underlying scan buffer.
|
||||
|
||||
Reference in New Issue
Block a user