packages feed

shomei-postgres-0.2.0.0: src/Shomei/Authorization/Role/Postgres.hs

-- | PostgreSQL interpreter for the 'RoleStore' port: the @shomei_roles@ registry, the
-- @shomei_role_grants@ table (with an optional expiry), and the @shomei_role_permissions@
-- role→permission definitions.
--
-- @DefineRole@, @GrantRole@, @RevokeRole@, @AllowPermission@, and @DisallowPermission@ report
-- whether they changed anything by reading @rowsAffected@; the @ON CONFLICT@ clauses make the
-- inserts idempotent, so a caller publishes an audit event only on a real state change. @GrantRole@
-- is an upsert guarded by @expires_at IS DISTINCT FROM@, so re-granting with a different expiry
-- still reports a change while an identical re-grant stays silent.
--
-- Like 'Shomei.Audit.Reader.Postgres' this interpreter needs no @IOE :> es@ constraint: every
-- operation goes through the @Database@ effect with no @liftIO@.
module Shomei.Authorization.Role.Postgres
  ( runRoleStorePostgres,
  )
where

import Contravariant.Extras (contrazip2, contrazip3, contrazip5)
import Data.Int (Int64)
import Data.Set qualified as Set
import Data.UUID (UUID)
import Effectful (Eff, (:>))
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.Authorization.Claims.Domain (Permission (..), Role (..))
import Shomei.Authorization.Role.Store (RoleDefinition (..), RoleStore (..))
import Shomei.Error (AuthError (..))
import Shomei.Id (userIdToUUID)
import Shomei.Persistence.Database.Postgres (Database, postgresUnavailable, runSession)
import Shomei.Prelude

runRoleStorePostgres ::
  (Database :> es, Error AuthError :> es) =>
  Eff (RoleStore : es) a ->
  Eff es a
runRoleStorePostgres = interpret_ \case
  DefineRole (Role r) desc ts -> do
    res <- runSession (Session.statement (r, desc, ts) defineRoleStmt)
    changed <$> either dbFail pure res
  ListDefinedRoles -> do
    res <- runSession (Session.statement () listDefinedRolesStmt)
    either dbFail pure res
  GrantRole uid (Role r) by expiry ts -> do
    let row = (userIdToUUID uid, r, userIdToUUID <$> by, expiry, ts)
    res <- runSession (Session.statement row grantRoleStmt)
    changed <$> either dbFail pure res
  RevokeRole uid (Role r) -> do
    res <- runSession (Session.statement (userIdToUUID uid, r) revokeRoleStmt)
    changed <$> either dbFail pure res
  ListRolesForUser uid asOf -> do
    res <- runSession (Session.statement (userIdToUUID uid, asOf) listRolesForUserStmt)
    Set.fromList . map Role <$> either dbFail pure res
  AllowPermission (Role r) (Permission p) ts -> do
    res <- runSession (Session.statement (r, p, ts) allowPermissionStmt)
    changed <$> either dbFail pure res
  DisallowPermission (Role r) (Permission p) -> do
    res <- runSession (Session.statement (r, p) disallowPermissionStmt)
    changed <$> either dbFail pure res
  ListPermissionsForRole (Role r) -> do
    res <- runSession (Session.statement r permissionsForRoleStmt)
    Set.fromList . map Permission <$> either dbFail pure res
  PermissionsForRoles roles -> do
    let names = map (\(Role r) -> r) (Set.toList roles)
    res <- runSession (Session.statement names permissionsForRolesStmt)
    Set.fromList . map Permission <$> either dbFail pure res
  where
    dbFail = throwError . postgresUnavailable
    changed :: Int64 -> Bool
    changed = (> 0)

roleDefinitionDecoder :: D.Row RoleDefinition
roleDefinitionDecoder =
  RoleDefinition
    <$> (Role <$> D.column (D.nonNullable D.text))
    <*> D.column (D.nullable D.text)
    <*> D.column (D.nonNullable D.timestamptz)

-- | @rowsAffected@ is 0 when the role was already defined. The existing description is left
-- untouched — a re-definition is a no-op, not an update.
defineRoleStmt :: Statement (Text, Maybe Text, UTCTime) Int64
defineRoleStmt =
  preparable
    """
    INSERT INTO shomei.shomei_roles (role, description, created_at)
    VALUES ($1, $2, $3)
    ON CONFLICT (role) DO NOTHING
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.rowsAffected

listDefinedRolesStmt :: Statement () [RoleDefinition]
listDefinedRolesStmt =
  preparable
    """
    SELECT role, description, created_at
    FROM shomei.shomei_roles
    ORDER BY role
    """
    E.noParams
    (D.rowList roleDefinitionDecoder)

-- | Upsert. @rowsAffected@ is 0 only when an identical grant (same expiry) already existed: the
-- @IS DISTINCT FROM@ guard means a re-grant that changes the expiry still updates the row and
-- reports a change, so the workflow re-audits it, while an unchanged re-grant stays silent.
grantRoleStmt :: Statement (UUID, Text, Maybe UUID, Maybe UTCTime, UTCTime) Int64
grantRoleStmt =
  preparable
    """
    INSERT INTO shomei.shomei_role_grants (user_id, role, granted_by, expires_at, granted_at)
    VALUES ($1, $2, $3, $4, $5)
    ON CONFLICT (user_id, role) DO UPDATE
      SET expires_at = EXCLUDED.expires_at,
          granted_by = EXCLUDED.granted_by,
          granted_at = EXCLUDED.granted_at
      WHERE shomei_role_grants.expires_at IS DISTINCT FROM EXCLUDED.expires_at
    """
    ( contrazip5
        (E.param (E.nonNullable E.uuid))
        (E.param (E.nonNullable E.text))
        (E.param (E.nullable E.uuid))
        (E.param (E.nullable E.timestamptz))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.rowsAffected

-- | @rowsAffected@ is 0 when there was no such grant to remove.
revokeRoleStmt :: Statement (UUID, Text) Int64
revokeRoleStmt =
  preparable
    """
    DELETE FROM shomei.shomei_role_grants
    WHERE user_id = $1 AND role = $2
    """
    (contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.text)))
    D.rowsAffected

-- | Expiry-filtered as of $2: a grant whose @expires_at@ is at or before the instant is excluded.
-- A NULL @expires_at@ (forever) always passes.
listRolesForUserStmt :: Statement (UUID, UTCTime) [Text]
listRolesForUserStmt =
  preparable
    """
    SELECT role
    FROM shomei.shomei_role_grants
    WHERE user_id = $1 AND (expires_at IS NULL OR expires_at > $2)
    ORDER BY role
    """
    (contrazip2 (E.param (E.nonNullable E.uuid)) (E.param (E.nonNullable E.timestamptz)))
    (D.rowList (D.column (D.nonNullable D.text)))

-- | @rowsAffected@ is 0 when the permission was already attached to the role.
allowPermissionStmt :: Statement (Text, Text, UTCTime) Int64
allowPermissionStmt =
  preparable
    """
    INSERT INTO shomei.shomei_role_permissions (role, permission, created_at)
    VALUES ($1, $2, $3)
    ON CONFLICT (role, permission) DO NOTHING
    """
    ( contrazip3
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.text))
        (E.param (E.nonNullable E.timestamptz))
    )
    D.rowsAffected

-- | @rowsAffected@ is 0 when there was no such attachment to remove.
disallowPermissionStmt :: Statement (Text, Text) Int64
disallowPermissionStmt =
  preparable
    """
    DELETE FROM shomei.shomei_role_permissions
    WHERE role = $1 AND permission = $2
    """
    (contrazip2 (E.param (E.nonNullable E.text)) (E.param (E.nonNullable E.text)))
    D.rowsAffected

permissionsForRoleStmt :: Statement Text [Text]
permissionsForRoleStmt =
  preparable
    """
    SELECT permission
    FROM shomei.shomei_role_permissions
    WHERE role = $1
    ORDER BY permission
    """
    (E.param (E.nonNullable E.text))
    (D.rowList (D.column (D.nonNullable D.text)))

-- | The deduplicated union of permissions across a role set — one round trip on the mint path.
permissionsForRolesStmt :: Statement [Text] [Text]
permissionsForRolesStmt =
  preparable
    """
    SELECT DISTINCT permission
    FROM shomei.shomei_role_permissions
    WHERE role = ANY ($1)
    ORDER BY permission
    """
    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))
    (D.rowList (D.column (D.nonNullable D.text)))