shomei-postgres-0.2.0.0: src/Shomei/ServiceAccount/Postgres.hs
-- | PostgreSQL interpreter for the EP-4 service-account store.
module Shomei.ServiceAccount.Postgres
( runServiceAccountStorePostgres,
)
where
import Contravariant.Extras (contrazip2, contrazip3, contrazip8)
import Data.Aeson (Result (..), Value)
import Data.Set qualified as Set
import Data.Text qualified as Text
import Data.UUID (UUID)
import Effectful (Eff, IOE, (:>))
import Effectful.Dispatch.Dynamic (interpret_)
import Effectful.Error.Static (Error, throwError)
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Shomei.Error (AuthError (..))
import Shomei.Id
( serviceAccountDbIdFromUUID,
serviceAccountDbIdToUUID,
userIdFromUUID,
userIdToUUID,
)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude
import Shomei.ServiceAccount.Domain
( NewServiceAccount (..),
ServiceAccount (..),
ServiceAccountStatus (..),
)
import Shomei.ServiceAccount.Store (ServiceAccountStore (..))
-- | The stored row, column order matching @shomei_service_accounts@:
-- @(service_account_id, client_id, user_id, secret_hash, display_name, allowed_scopes, status,
-- created_at, rotated_at, revoked_at)@. @allowed_scopes :: Set Scope@ rides as a @jsonb@ array
-- of scope texts, as @shomei_webauthn_credentials.transports@ does.
type ServiceAccountRow = (UUID, Text, UUID, Text, Text, Value, Text, UTCTime, Maybe UTCTime, Maybe UTCTime)
-- | The @status@ column's two values. Kept in one place so the encoder and the decoder cannot
-- drift: a typo would silently make every account look revoked.
renderStatus :: ServiceAccountStatus -> Text
renderStatus = \case
ServiceAccountActive -> "active"
ServiceAccountRevoked -> "revoked"
parseStatus :: Text -> Either Text ServiceAccountStatus
parseStatus = \case
"active" -> Right ServiceAccountActive
"revoked" -> Right ServiceAccountRevoked
other -> Left ("invalid service-account status: " <> other)
runServiceAccountStorePostgres ::
(Database :> es, IOE :> es, Error AuthError :> es) =>
Eff (ServiceAccountStore : es) a ->
Eff es a
runServiceAccountStorePostgres = interpret_ \case
CreateServiceAccount NewServiceAccount {serviceAccountId, clientId, userId, secretHash, displayName, allowedScopes, createdAt} -> do
let sa =
ServiceAccount
{ serviceAccountId,
clientId,
userId,
secretHash,
displayName,
allowedScopes,
status = ServiceAccountActive,
createdAt,
rotatedAt = Nothing,
revokedAt = Nothing
}
res <- runSession (Session.statement (toInsertRow sa) insertStmt)
either dbFail (const (pure sa)) res
FindServiceAccountByClientId cid -> do
res <- runSession (Session.statement cid findByClientIdStmt)
row <- either dbFail pure res
traverse rebuild row
ListServiceAccounts -> do
res <- runSession (Session.statement () listStmt)
rows <- either dbFail pure res
traverse rebuild rows
RotateServiceAccountSecret sid h t -> do
res <- runSession (Session.statement (serviceAccountDbIdToUUID sid, h, t) rotateSecretStmt)
either dbFail (const (pure ())) res
RevokeServiceAccount sid t -> do
res <- runSession (Session.statement (serviceAccountDbIdToUUID sid, t) revokeStmt)
either dbFail (const (pure ())) res
where
dbFail = throwError . postgresUnavailable
rebuild r = either (throwError . InternalAuthError) pure (rebuildServiceAccount r)
-- | The eight columns an INSERT writes. @rotated_at@ and @revoked_at@ are always NULL on a
-- fresh row, so they are literals in the statement rather than parameters.
type InsertRow = (UUID, Text, UUID, Text, Text, Value, Text, UTCTime)
toInsertRow :: ServiceAccount -> InsertRow
toInsertRow ServiceAccount {serviceAccountId, clientId, userId, secretHash, displayName, allowedScopes, status, createdAt} =
( serviceAccountDbIdToUUID serviceAccountId,
clientId,
userIdToUUID userId,
secretHash,
displayName,
toJSON (Set.toList allowedScopes),
renderStatus status,
createdAt
)
rebuildServiceAccount :: ServiceAccountRow -> Either Text ServiceAccount
rebuildServiceAccount (said, cid, uid, sh, dn, scopesJson, st, ca, ra, rva) = do
scopes <- case fromJSON scopesJson of
Success ss -> Right (Set.fromList ss)
Error msg -> Left ("invalid allowed_scopes json: " <> Text.pack msg)
status <- parseStatus st
pure
ServiceAccount
{ serviceAccountId = serviceAccountDbIdFromUUID said,
clientId = cid,
userId = userIdFromUUID uid,
secretHash = sh,
displayName = dn,
allowedScopes = scopes,
status,
createdAt = ca,
rotatedAt = ra,
revokedAt = rva
}
serviceAccountRowDecoder :: D.Row ServiceAccountRow
serviceAccountRowDecoder =
(,,,,,,,,,)
<$> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.uuid)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.jsonb)
<*> D.column (D.nonNullable D.text)
<*> D.column (D.nonNullable D.timestamptz)
<*> D.column (D.nullable D.timestamptz)
<*> D.column (D.nullable D.timestamptz)
insertRowEncoder :: E.Params InsertRow
insertRowEncoder =
contrazip8
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.jsonb))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.timestamptz))
-- | The SELECT column list (matches 'ServiceAccountRow' / 'serviceAccountRowDecoder' order).
selectCols :: Text
selectCols =
"service_account_id, client_id, user_id, secret_hash, display_name, allowed_scopes, status, created_at, rotated_at, revoked_at"
insertStmt :: Statement InsertRow ()
insertStmt =
preparable
"""
INSERT INTO shomei.shomei_service_accounts
(service_account_id, client_id, user_id, secret_hash, display_name, allowed_scopes,
status, created_at, rotated_at, revoked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, NULL)
"""
insertRowEncoder
D.noResult
findByClientIdStmt :: Statement Text (Maybe ServiceAccountRow)
findByClientIdStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_service_accounts WHERE client_id = $1")
(E.param (E.nonNullable E.text))
(D.rowMaybe serviceAccountRowDecoder)
-- | Newest first, tie-broken by id so the order is total (the in-memory interpreter sorts the
-- same way, and the servant suite walks both).
listStmt :: Statement () [ServiceAccountRow]
listStmt =
preparable
("SELECT " <> selectCols <> " FROM shomei.shomei_service_accounts ORDER BY created_at DESC, service_account_id DESC")
E.noParams
(D.rowList serviceAccountRowDecoder)
rotateSecretStmt :: Statement (UUID, Text, UTCTime) ()
rotateSecretStmt =
preparable
"""
UPDATE shomei.shomei_service_accounts
SET secret_hash = $2, rotated_at = $3
WHERE service_account_id = $1
"""
( contrazip3
(E.param (E.nonNullable E.uuid))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.timestamptz))
)
D.noResult
revokeStmt :: Statement (UUID, UTCTime) ()
revokeStmt =
preparable
"""
UPDATE shomei.shomei_service_accounts
SET status = 'revoked', revoked_at = $2
WHERE service_account_id = $1
"""
(contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
D.noResult