packages feed

kioku-api-0.4.0.0: src/Kioku/Api/Access.hs

-- | Memory spaces, principals, and the authorization context Kioku's core requires.
--
-- Kioku stores memory. It does not authenticate anyone, hold a roster of users or teams, or
-- decide who may read what — and it takes no dependency on anything that does. What it requires
-- is that somebody else has already decided, and says so by handing it a 'MemoryAccessContext'.
--
-- There are exactly two ways to obtain one.
--
-- A trusted in-process host — a CLI, a test, an application that authenticated its user long
-- before it reached the memory layer — calls 'assumeAuthorizedMemoryContext'. Nothing else is
-- required: no directory, no authorization engine, no configuration. This is the ordinary case
-- and it is why Kioku is usable standalone.
--
-- A host serving untrusted callers uses 'authorizeMemoryAccess', which runs three gates in a
-- fixed order and refuses to skip any of them:
--
-- 1. a coarse credential claim ('MemoryCoarseScope') proves the caller may talk to Kioku about
--    this /kind/ of action at all;
-- 2. a directory ('PrincipalDirectory') resolves the authenticated subject to a
--    'PrincipalRef', which is what makes an unlinked credential or a paused agent fail closed;
-- 3. an authorization engine ('PermissionChecker') decides whether that principal may perform
--    this action on /this memory space/.
--
-- Each gate answers a question the others cannot. A coarse @kioku:read@ scope says nothing about
-- which space; a resolved principal says nothing about permission; and a permission check on a
-- subject nobody vouched for is a check on a string the caller made up.
--
-- The two seams in steps 2 and 3 are records of plain functions. Kioku names no identity
-- service, and the object type and permission names it asks about come from the host as a
-- 'MemoryAuthorizationBinding'. See @docs\/user\/integrations.md@ for a worked integration.
module Kioku.Api.Access
  ( -- * The isolation boundary
    MemorySpaceId,
    mkMemorySpaceId,
    memorySpaceIdText,
    legacyMemorySpaceId,

    -- * Principals
    PrincipalRef,
    mkPrincipalRef,
    principalRefText,
    MemoryActor (..),
    MemoryOwner (..),
    actorPrincipal,
    ownerPrincipal,

    -- * Principals as they appear on stored facts
    LegacyPrincipalRef,
    legacyPrincipalRef,
    legacyPrincipalRefText,
    RecordedPrincipal (..),
    recordedPrincipalText,
    parseRecordedPrincipal,

    -- * Kioku's own action vocabulary
    MemoryPermission (..),
    allMemoryPermissions,
    memoryPermissionText,
    parseMemoryPermission,

    -- * Naming the authorization object, which Kioku does not own
    MemoryObjectType,
    mkMemoryObjectType,
    memoryObjectTypeText,
    MemoryPermissionName,
    mkMemoryPermissionName,
    memoryPermissionNameText,
    MemoryCoarseScope,
    mkMemoryCoarseScope,
    memoryCoarseScopeText,
    MemoryObjectRef (..),
    memoryObjectRefText,
    MemoryPermissionBinding (..),
    MemoryAuthorizationBinding,
    mkMemoryAuthorizationBinding,
    memoryPermissionBinding,
    memorySpaceObjectRef,

    -- * Freshness
    MemoryDecisionToken,
    mkMemoryDecisionToken,
    memoryDecisionTokenText,
    MemoryFreshness (..),
    atLeastAsFresh,

    -- * What an authorization seam answers
    MemoryDecisionOutcome (..),
    MemoryDecision (..),

    -- * Why access was refused
    MemoryAccessDenial (..),

    -- * The authenticated caller
    AuthenticatedSubject (..),

    -- * The seams Kioku does not implement
    PrincipalDirectory (..),
    PermissionChecker (..),
    MemoryContextProvider (..),
    assumeAuthorizedContextProvider,

    -- * The authorized decision
    MemoryAccessContext,
    memoryContextSpace,
    memoryContextActor,
    memoryContextPermissions,
    memoryContextDecisionToken,
    memoryContextAllows,
    memoryContextFreshness,
    memoryContextRecordedActor,
    assumeAuthorizedMemoryContext,
    authorizeMemoryAccess,
  )
where

import Data.List.NonEmpty qualified as NonEmpty
import Data.Set qualified as Set
import Kioku.Api.Access.Internal
import Kioku.Prelude

-- | Run the three gates for one memory space and a set of requested actions, and mint a
-- 'MemoryAccessContext' only if every one of them passes.
--
-- The order is fixed and the failures stay distinct. A missing coarse scope, an unresolved
-- principal, a denial, and a conditional answer are four different 'MemoryAccessDenial'
-- constructors, and a caller must keep them apart all the way out: none of them may become a
-- successful recall that happens to return no rows. A caller cannot tell "you may not look here"
-- from "there is nothing here", and only one of those is worth acting on.
--
-- All requested permissions are checked, not just the first. A context that claimed five
-- permissions on the strength of one check would be exactly the confused-deputy bug the whole
-- boundary exists to prevent.
--
-- The @freshness@ argument is what a caller passes after writing a grant or a membership: hand
-- back the 'MemoryDecisionToken' from the write (or from a previous decision, via
-- 'memoryContextFreshness') and the check is guaranteed to observe it. Without it a replica that
-- has not caught up can deny a permission that already exists. The minted context carries the
-- token of the last check it ran, so a follow-up read can chain from it.
--
-- A 'MemoryConditional' answer is treated as a refusal. The relationship exists but is gated on
-- context this request did not supply, and quietly promoting that to an allow is how a
-- time-limited grant becomes a permanent one.
authorizeMemoryAccess ::
  (Monad m) =>
  MemoryAuthorizationBinding ->
  PrincipalDirectory m ->
  PermissionChecker m ->
  MemoryFreshness ->
  AuthenticatedSubject ->
  MemorySpaceId ->
  NonEmpty MemoryPermission ->
  m (Either MemoryAccessDenial MemoryAccessContext)
authorizeMemoryAccess binding directory authorizer freshness subject spaceId requested =
  case coarseGate of
    Left denial -> pure (Left denial)
    Right () -> do
      resolved <- directory.resolvePrincipal subject.subjectId
      case resolved of
        Nothing -> pure (Left (MemoryPrincipalUnresolved subject.subjectId))
        Just principal -> checkAll principal Nothing permissions
  where
    permissions = NonEmpty.toList requested
    object = memorySpaceObjectRef binding spaceId

    -- Every requested action must clear its coarse claim before the directory is consulted, so
    -- an unauthorized caller cannot use Kioku as an oracle for which subjects exist.
    coarseGate =
      case filter scopeMissing permissions of
        [] -> Right ()
        permission : _ ->
          Left (MemoryCoarseScopeMissing (memoryPermissionBinding binding permission).coarseScope)

    scopeMissing permission =
      not (Set.member (memoryPermissionBinding binding permission).coarseScope subject.grantedScopes)

    checkAll principal latestToken = \case
      [] ->
        pure
          ( Right
              MemoryAccessContext
                { memorySpaceId = spaceId,
                  actor = MemoryActor principal,
                  grantedPermissions = Set.fromList permissions,
                  decisionToken = latestToken
                }
          )
      permission : rest -> do
        decision <-
          authorizer.checkMemoryPermission
            freshness
            principal
            (memoryPermissionBinding binding permission).objectPermission
            object
        case decision.outcome of
          MemoryDenied -> pure (Left (MemoryPermissionDenied spaceId permission))
          MemoryConditional obligations ->
            pure (Left (MemoryDecisionConditional spaceId permission obligations))
          MemoryAllowed -> checkAll principal (Just decision.checkedAt) rest