{- | The AWS CodeArtifact leaf of the outbound-credential handle: mint a
short-lived registry bearer token via CodeArtifact's @GetAuthorizationToken@.

This is the one genuinely cloud-specific part of outbound auth -- everything else
(caching, proactive refresh, single-flight, the circuit breaker) is the
cloud-agnostic policy in "Ecluse.Core.Credential.Refresh", which this module wires its
mint into. The leaf itself is tiny: build an @amazonka@ 'Env' once (credentials
discovered the standard AWS way -- environment, instance role, container role, SSO,
STS), then on each mint call @GetAuthorizationToken@ and return the token together
with its real expiry so the refresh policy schedules off the token's own
lifetime (CodeArtifact tokens last up to 12h).

This is __control plane__ only: @amazonka@ obtains the token, and the data plane
that then uses it to publish to the registry stays on @http-client@ (see
@docs\/architecture\/web-layer.md@ → "Control plane vs data plane"). The 'Env' is
constructed once at provider creation and captured in the mint closure, so the
backend's state never leaks into the proxy's @Env@\/@App@ (see
@docs\/architecture\/technology-stack.md@ → "Key Decisions").
-}
module Ecluse.Core.Credential.CodeArtifact (
    -- * Configuration
    CodeArtifactConfig (..),

    -- * The provider
    newCodeArtifactProvider,
    providerForEnv,
) where

import Amazonka qualified as AWS
import Amazonka.CodeArtifact.GetAuthorizationToken qualified as CA
import Control.Monad.Trans.Resource (runResourceT)
import Data.Time (getCurrentTime)
import Lens.Micro (Lens', (?~), (^.))
import UnliftIO.Exception (throwIO)

import Ecluse.Core.Credential (AuthToken (..), CredentialProvider, mkSecret)
import Ecluse.Core.Credential.Refresh (
    CredentialReporters (..),
    RefreshConfig (..),
    defaultRefreshConfig,
    refreshingProvider,
 )

{- The mint's one failure: @GetAuthorizationToken@ succeeded but carried no token.
Thrown (not a stringly exception) because the refresh breaker runs this leaf and
catches 'SomeException' to count failures and trip -- the leaf must throw to be seen,
and a returned value would fight that contract (STYLE.md section 11.4). Not exported
for the same reason: the breaker sees it as a 'SomeException'. -}
data CodeArtifactMintError = AuthorizationTokenMissing
    deriving stock (CodeArtifactMintError -> CodeArtifactMintError -> Bool
(CodeArtifactMintError -> CodeArtifactMintError -> Bool)
-> (CodeArtifactMintError -> CodeArtifactMintError -> Bool)
-> Eq CodeArtifactMintError
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: CodeArtifactMintError -> CodeArtifactMintError -> Bool
== :: CodeArtifactMintError -> CodeArtifactMintError -> Bool
$c/= :: CodeArtifactMintError -> CodeArtifactMintError -> Bool
/= :: CodeArtifactMintError -> CodeArtifactMintError -> Bool
Eq, Int -> CodeArtifactMintError -> ShowS
[CodeArtifactMintError] -> ShowS
CodeArtifactMintError -> String
(Int -> CodeArtifactMintError -> ShowS)
-> (CodeArtifactMintError -> String)
-> ([CodeArtifactMintError] -> ShowS)
-> Show CodeArtifactMintError
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> CodeArtifactMintError -> ShowS
showsPrec :: Int -> CodeArtifactMintError -> ShowS
$cshow :: CodeArtifactMintError -> String
show :: CodeArtifactMintError -> String
$cshowList :: [CodeArtifactMintError] -> ShowS
showList :: [CodeArtifactMintError] -> ShowS
Show)

instance Exception CodeArtifactMintError

{- | What the CodeArtifact leaf needs to mint a token. The AWS /credentials/ used
to make the call are __not__ here: they are discovered the standard AWS way
('AWS.discover') from the ambient environment (env vars, instance\/container role,
SSO, STS), so the proxy never holds long-lived AWS keys itself.
-}
data CodeArtifactConfig = CodeArtifactConfig
    { CodeArtifactConfig -> Text
caRegion :: Text
    -- ^ The AWS region the CodeArtifact domain lives in (e.g. @"us-east-1"@).
    , CodeArtifactConfig -> Text
caDomain :: Text
    -- ^ The CodeArtifact domain that scopes the token.
    , CodeArtifactConfig -> Maybe Text
caDomainOwner :: Maybe Text
    {- ^ The 12-digit account number that owns the domain, when it differs from
    the calling account ('Nothing' to default to the caller's account).
    -}
    , CodeArtifactConfig -> Maybe Natural
caDurationSeconds :: Maybe Natural
    {- ^ Requested token lifetime in seconds (@900@-@43200@, i.e. 15 min to 12 h);
    'Nothing' lets CodeArtifact default it (it ties the token to the caller's
    role-credential expiry). The refresh policy adapts to whatever expiry the
    minted token actually carries, so this is only a preference.
    -}
    }
    deriving stock (CodeArtifactConfig -> CodeArtifactConfig -> Bool
(CodeArtifactConfig -> CodeArtifactConfig -> Bool)
-> (CodeArtifactConfig -> CodeArtifactConfig -> Bool)
-> Eq CodeArtifactConfig
forall a. (a -> a -> Bool) -> (a -> a -> Bool) -> Eq a
$c== :: CodeArtifactConfig -> CodeArtifactConfig -> Bool
== :: CodeArtifactConfig -> CodeArtifactConfig -> Bool
$c/= :: CodeArtifactConfig -> CodeArtifactConfig -> Bool
/= :: CodeArtifactConfig -> CodeArtifactConfig -> Bool
Eq, Int -> CodeArtifactConfig -> ShowS
[CodeArtifactConfig] -> ShowS
CodeArtifactConfig -> String
(Int -> CodeArtifactConfig -> ShowS)
-> (CodeArtifactConfig -> String)
-> ([CodeArtifactConfig] -> ShowS)
-> Show CodeArtifactConfig
forall a.
(Int -> a -> ShowS) -> (a -> String) -> ([a] -> ShowS) -> Show a
$cshowsPrec :: Int -> CodeArtifactConfig -> ShowS
showsPrec :: Int -> CodeArtifactConfig -> ShowS
$cshow :: CodeArtifactConfig -> String
show :: CodeArtifactConfig -> String
$cshowList :: [CodeArtifactConfig] -> ShowS
showList :: [CodeArtifactConfig] -> ShowS
Show)

{- | Build a refreshing 'CredentialProvider' backed by CodeArtifact
@GetAuthorizationToken@. Discovers AWS credentials the standard way
('AWS.discover') and hands the resulting 'AWS.Env' to 'providerForEnv'.

Mints once eagerly to seed the cache, so a misconfiguration (bad region, missing
credentials, no permission) fails here at construction rather than on the first
mirror write.

The 'CredentialReporters' carry the telemetry observers the refresh policy records
through (the mint breaker's state and each refresh outcome); pass
'Ecluse.Core.Credential.Refresh.noCredentialReporters' for an unobserved provider.
-}
newCodeArtifactProvider :: CredentialReporters -> CodeArtifactConfig -> IO CredentialProvider
newCodeArtifactProvider :: CredentialReporters -> CodeArtifactConfig -> IO CredentialProvider
newCodeArtifactProvider CredentialReporters
reporters CodeArtifactConfig
cfg =
    (EnvNoAuth -> IO Env) -> IO Env
forall (m :: * -> *). MonadIO m => (EnvNoAuth -> m Env) -> m Env
AWS.newEnv EnvNoAuth -> IO Env
forall (m :: * -> *) (withAuth :: * -> *).
(MonadCatch m, MonadIO m, Foldable withAuth) =>
Env' withAuth -> m Env
AWS.discover IO Env -> (Env -> IO CredentialProvider) -> IO CredentialProvider
forall a b. IO a -> (a -> IO b) -> IO b
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= \Env
env -> CredentialReporters
-> Env -> CodeArtifactConfig -> IO CredentialProvider
providerForEnv CredentialReporters
reporters Env
env CodeArtifactConfig
cfg

{- | Build the provider over a caller-supplied @amazonka@ 'Env' -- the boundary the
production 'newCodeArtifactProvider' wraps with credential discovery. The config's
region is applied to the 'Env', and each mint calls @GetAuthorizationToken@ through
it under the cache\/proactive-refresh\/single-flight\/breaker policy of
"Ecluse.Core.Credential.Refresh" (so the token API is not re-hit per request), reporting its
refresh and breaker signals through the given 'CredentialReporters'. Exposed so a test
can drive the real mint against an 'Env' aimed at a stub endpoint, with no live AWS.
-}
providerForEnv :: CredentialReporters -> AWS.Env -> CodeArtifactConfig -> IO CredentialProvider
providerForEnv :: CredentialReporters
-> Env -> CodeArtifactConfig -> IO CredentialProvider
providerForEnv CredentialReporters
reporters Env
env CodeArtifactConfig
cfg =
    RefreshConfig -> IO CredentialProvider
refreshingProvider
        RefreshConfig
defaultRefreshConfig
            { rcMint = mintToken (regioned env) (tokenRequest cfg)
            , rcClock = getCurrentTime
            , rcBreakerReporter = crBreakerReporter reporters
            , rcRefreshReporter = crRefreshReporter reporters
            }
  where
    regioned :: AWS.Env -> AWS.Env
    regioned :: Env -> Env
regioned Env
e = Env
e{AWS.region = AWS.Region' (caRegion cfg)}

-- The GetAuthorizationToken request the configuration describes.
tokenRequest :: CodeArtifactConfig -> CA.GetAuthorizationToken
tokenRequest :: CodeArtifactConfig -> GetAuthorizationToken
tokenRequest CodeArtifactConfig
cfg =
    Lens' GetAuthorizationToken (Maybe Text)
-> Maybe Text -> GetAuthorizationToken -> GetAuthorizationToken
forall s a. Lens' s (Maybe a) -> Maybe a -> s -> s
setOptional (Maybe Text -> f (Maybe Text))
-> GetAuthorizationToken -> f GetAuthorizationToken
Lens' GetAuthorizationToken (Maybe Text)
CA.getAuthorizationToken_domainOwner (CodeArtifactConfig -> Maybe Text
caDomainOwner CodeArtifactConfig
cfg)
        (GetAuthorizationToken -> GetAuthorizationToken)
-> (GetAuthorizationToken -> GetAuthorizationToken)
-> GetAuthorizationToken
-> GetAuthorizationToken
forall b c a. (b -> c) -> (a -> b) -> a -> c
. Lens' GetAuthorizationToken (Maybe Natural)
-> Maybe Natural -> GetAuthorizationToken -> GetAuthorizationToken
forall s a. Lens' s (Maybe a) -> Maybe a -> s -> s
setOptional (Maybe Natural -> f (Maybe Natural))
-> GetAuthorizationToken -> f GetAuthorizationToken
Lens' GetAuthorizationToken (Maybe Natural)
CA.getAuthorizationToken_durationSeconds (CodeArtifactConfig -> Maybe Natural
caDurationSeconds CodeArtifactConfig
cfg)
        (GetAuthorizationToken -> GetAuthorizationToken)
-> GetAuthorizationToken -> GetAuthorizationToken
forall a b. (a -> b) -> a -> b
$ Text -> GetAuthorizationToken
CA.newGetAuthorizationToken (CodeArtifactConfig -> Text
caDomain CodeArtifactConfig
cfg)

-- One mint: call GetAuthorizationToken and lift the response into an AuthToken.
mintToken :: AWS.Env -> CA.GetAuthorizationToken -> IO AuthToken
mintToken :: Env -> GetAuthorizationToken -> IO AuthToken
mintToken Env
env GetAuthorizationToken
request = do
    response <- ResourceT IO GetAuthorizationTokenResponse
-> IO GetAuthorizationTokenResponse
forall (m :: * -> *) a. MonadUnliftIO m => ResourceT m a -> m a
runResourceT (Env
-> GetAuthorizationToken
-> ResourceT IO (AWSResponse GetAuthorizationToken)
forall (m :: * -> *) a.
(MonadResource m, AWSRequest a) =>
Env -> a -> m (AWSResponse a)
AWS.send Env
env GetAuthorizationToken
request)
    secret <- case response ^. CA.getAuthorizationTokenResponse_authorizationToken of
        Just Text
token -> Secret -> IO Secret
forall a. a -> IO a
forall (f :: * -> *) a. Applicative f => a -> f a
pure (Text -> Secret
mkSecret Text
token)
        Maybe Text
Nothing -> CodeArtifactMintError -> IO Secret
forall (m :: * -> *) e a. (MonadIO m, Exception e) => e -> m a
throwIO CodeArtifactMintError
AuthorizationTokenMissing
    pure
        AuthToken
            { authSecret = secret
            , authExpiresAt = response ^. CA.getAuthorizationTokenResponse_expiration
            }

{- | Set an optional request field only when present, leaving the @amazonka@
default ('Nothing') in place otherwise.
-}
setOptional :: Lens' s (Maybe a) -> Maybe a -> s -> s
setOptional :: forall s a. Lens' s (Maybe a) -> Maybe a -> s -> s
setOptional Lens' s (Maybe a)
l = (s -> s) -> (a -> s -> s) -> Maybe a -> s -> s
forall b a. b -> (a -> b) -> Maybe a -> b
maybe s -> s
forall a. a -> a
id ((Maybe a -> Identity (Maybe a)) -> s -> Identity s
Lens' s (Maybe a)
l ((Maybe a -> Identity (Maybe a)) -> s -> Identity s) -> a -> s -> s
forall s t a b. ASetter s t a (Maybe b) -> b -> s -> t
?~)