packages feed

secretspec 0.19.1 → 0.20.0

raw patch · 3 files changed

+112/−28 lines, 3 files

Files

secretspec.cabal view
@@ -1,9 +1,9 @@ cabal-version:      2.4 name:               secretspec-version:            0.19.1+version:            0.20.0 synopsis:           Haskell SDK for SecretSpec, a declarative secrets manager description:-  A thin client over the @secretspec-ffi@ C ABI (linked at build time).+  A thin client over the @libsecretspec@ C ABI (linked at build time).   Resolution (providers, chains, profiles, generation, @as_path@) happens in the   Rust core; this package marshals a JSON request to the native library and   parses the response, mirroring the Rust derive crate's vocabulary.@@ -17,8 +17,8 @@  flag use-pkg-config   description:-    Locate secretspec_ffi and its link dependencies through pkg-config-    (secretspec_ffi.pc) instead of command-line library and linker paths.+    Locate secretspec and its link dependencies through pkg-config+    (libsecretspec.pc) instead of command-line library and linker paths.   default:            False   manual:             True @@ -35,14 +35,14 @@   -- the static archive; pkg-config may select a static or shared install.   --   -- Without the flag: point --extra-lib-dirs at a directory containing ONLY-  -- libsecretspec_ffi.a (so -lsecretspec_ffi resolves to the archive, not a+  -- libsecretspec.a (so -lsecretspec resolves to the archive, not a   -- co-located .so), and pass the archive's transitive native deps via   -- --ghc-options=-optl<lib> (capture them with-  -- `cargo rustc -p secretspec-ffi --crate-type staticlib -- --print native-static-libs`).+  -- `cargo rustc -p libsecretspec --crate-type staticlib -- --print native-static-libs`).   if flag(use-pkg-config)-    pkgconfig-depends: secretspec_ffi+    pkgconfig-depends: libsecretspec   else-    extra-libraries:  secretspec_ffi+    extra-libraries:  secretspec   -- The Darwin frameworks among the archive's native dependencies, declared   -- so every final link (executable, test-suite, ghci) receives them through   -- the compiler driver.
src/SecretSpec.hs view
@@ -3,7 +3,7 @@  -- | Haskell SDK for SecretSpec, a declarative secrets manager. ----- A thin client over the @secretspec-ffi@ C ABI, linked at build time.+-- A thin client over the @libsecretspec@ C ABI, linked at build time. -- Resolution (providers, fallback chains, profiles, generation, @as_path@) -- happens entirely in the Rust core; this module marshals a JSON request to -- @secretspec_resolve@, parses the response envelope, and exposes it with the@@ -19,12 +19,15 @@ module SecretSpec   ( -- * Builder     Builder+  , CallerContext(..)   , builder   , withPath+  , withInlineSpec   , withProvider   , withProfile   , withScope   , withReason+  , withCaller   , withNoValues     -- * Resolve (value-carrying)   , Resolved(..)@@ -63,13 +66,16 @@ import           System.Directory (doesFileExist, removeFile) import qualified System.Environment.Blank as Env --- The three C ABI functions, linked at build time. The default build embeds the+-- The four C ABI functions, linked at build time. The default build embeds the -- static archive; pkg-config builds may use a static or shared install. They -- are declared @safe@ because @secretspec_resolve@ may block on provider I/O -- (1Password, LastPass), and a @safe@ call lets other Haskell threads run. foreign import ccall safe "secretspec_resolve"   c_secretspec_resolve :: CString -> IO CString +foreign import ccall safe "secretspec_call"+  c_secretspec_call :: CString -> IO CString+ foreign import ccall safe "secretspec_free"   c_secretspec_free :: CString -> IO () @@ -173,18 +179,35 @@   , bProfile  :: Maybe Text   , bScope    :: Maybe Text   , bReason   :: Maybe Text+  , bCaller   :: Maybe CallerContext   , bNoValues :: Bool+  , bInline   :: Maybe (Value, Text)   } +-- | Caller-asserted software-integration context (SecretSpec 0.20+). It is+-- audit metadata and never supplies the user access reason.+data CallerContext = CallerContext+  { callerName      :: Text+  , callerVersion   :: Maybe Text+  , callerOperation :: Maybe Text+  , callerResource  :: Maybe Text+  } deriving (Show, Eq)+ -- | A builder with no options set. builder :: Builder-builder = Builder Nothing Nothing Nothing Nothing Nothing False+builder = Builder Nothing Nothing Nothing Nothing Nothing Nothing False Nothing  -- | Resolve from a manifest at this path instead of walking up from the working -- directory. withPath :: Text -> Builder -> Builder-withPath v b = b { bPath = Just v }+withPath v b = b { bPath = Just v, bInline = Nothing } +-- | Resolve strict inline-spec v1 at its logical base directory (0.20+).+-- The static linker requires @secretspec_call@, so an older native archive+-- fails at link time instead of falling back to a filesystem manifest.+withInlineSpec :: Value -> Text -> Builder -> Builder+withInlineSpec spec baseDir b = b { bPath = Nothing, bInline = Just (spec, baseDir) }+ -- | Override the provider (a @keyring:\/\/@-style URI or a configured alias). withProvider :: Text -> Builder -> Builder withProvider v b = b { bProvider = Just v }@@ -201,6 +224,10 @@ withReason :: Text -> Builder -> Builder withReason v b = b { bReason = Just v } +-- | Identify the invoking software integration (SecretSpec 0.20+).+withCaller :: CallerContext -> Builder -> Builder+withCaller v b = b { bCaller = Just v }+ -- | Omit secret values, returning only structure and provenance. withNoValues :: Bool -> Builder -> Builder withNoValues v b = b { bNoValues = v }@@ -257,7 +284,7 @@ -- missing, and 'SecretSpecError' for any other failure. load :: Builder -> IO Resolved load b = do-  resp <- callNative (requestBytes b Nothing)+  resp <- callNative (isInline b) (requestBytes b Nothing)   value <- responseValue resp resolveSchemaVersion "resolve"   (prov, prof, scope, secs, mreq, mopt) <- fromResult (parseEither pResolve value)   case mreq of@@ -279,7 +306,7 @@ -- status @"missing_required"@. report :: Builder -> IO Report report b = do-  resp <- callNative (requestBytes b (Just "report"))+  resp <- callNative (isInline b) (requestBytes b (Just "report"))   value <- responseValue resp reportSchemaVersion "report"   (prov, prof, scope, secs) <- fromResult (parseEither pReport value)   pure (Report prov prof scope secs)@@ -295,16 +322,37 @@ -- (@mode = Just "report"@), omitting unset options. requestBytes :: Builder -> Maybe Text -> BL.ByteString requestBytes b mode =-  encode . object $-    catMaybes-      [ ("path" .=) <$> bPath b-      , ("provider" .=) <$> bProvider b-      , ("profile" .=) <$> bProfile b-      , ("scope" .=) <$> bScope b-      , ("reason" .=) <$> bReason b+  case bInline b of+    Nothing -> encode options+    Just (spec, baseDir) -> encode $ object+      [ "request_version" .= (1 :: Int)+      , "operation" .= ("resolve" :: Text)+      , "source" .= object+          [ "kind" .= ("inline" :: Text)+          , "spec_version" .= (1 :: Int)+          , "base_dir" .= baseDir+          , "spec" .= spec+          ]+      , "options" .= options       ]-      ++ ["no_values" .= True | bNoValues b]-      ++ ["mode" .= m | Just m <- [mode]]+  where+    options = object $+      catMaybes+        [ ("path" .=) <$> bPath b+        , ("provider" .=) <$> bProvider b+        , ("profile" .=) <$> bProfile b+        , ("scope" .=) <$> bScope b+        , ("reason" .=) <$> bReason b+        , ("caller" .=) . callerValue <$> bCaller b+        ]+        ++ ["no_values" .= True | bNoValues b]+        ++ ["mode" .= m | Just m <- [mode]]+    callerValue caller = object . catMaybes $+      [ Just ("name" .= callerName caller)+      , ("version" .=) <$> callerVersion caller+      , ("operation" .=) <$> callerOperation caller+      , ("resource" .=) <$> callerResource caller+      ]  -- Marshal a request to secretspec_resolve and copy the response out before -- freeing the native allocation.@@ -314,13 +362,16 @@ -- around 'load') from landing between the call returning and the free being -- installed, and @finally@ guarantees the free runs whether @packCString@ -- succeeds, throws, or is interrupted — so the secret-bearing buffer never leaks.-callNative :: BL.ByteString -> IO BS.ByteString-callNative reqLazy =+isInline :: Builder -> Bool+isInline = maybe False (const True) . bInline++callNative :: Bool -> BL.ByteString -> IO BS.ByteString+callNative versioned reqLazy =   BS.useAsCString (BL.toStrict reqLazy) $ \creq ->     mask $ \restore -> do-      cresp <- c_secretspec_resolve creq+      cresp <- (if versioned then c_secretspec_call else c_secretspec_resolve) creq       if cresp == nullPtr-        then throwIO (SecretSpecError "ffi" "secretspec_resolve returned null")+        then throwIO (SecretSpecError "ffi" (if versioned then "secretspec_call returned null" else "secretspec_resolve returned null"))         else restore (BS.packCString cresp) `finally` c_secretspec_free cresp  -- Decode the envelope, unwrap @ok@/@error@, and check the schema version,@@ -347,7 +398,7 @@     T.concat       [ "unsupported ", kind, " schema version ", T.pack (show got)       , " (expected ", T.pack (show expected)-      , "); the secretspec-ffi library and this SDK are out of sync"+      , "); the libsecretspec library and this SDK are out of sync"       ]  fromResult :: Either String a -> IO a
test/Main.hs view
@@ -40,6 +40,8 @@    let tests =         [ ("abi_version_nonempty", testAbiVersion)+        , ("caller_context_builder", testCallerContextBuilder)+        , ("inline_spec", testInlineSpec)         , ("missing_required_throws", testMissingRequired)         , ("scoped_resolution", testScope)         , ("codegen", testCodegen)@@ -71,6 +73,37 @@ testAbiVersion = do   v <- S.abiVersion   expect (not (T.null v)) "abi version was empty"++testCallerContextBuilder :: IO ()+testCallerContextBuilder = do+  let caller = S.CallerContext "git" (Just "2.51.0")+        (Just "credential_get") (Just "github.com")+      configured = S.builder & S.withCaller caller+  configured `seq` pure ()++testInlineSpec :: IO ()+testInlineSpec = do+  tmp <- getTemporaryDirectory+  let dir = tmp </> "secretspec-hs-inline"+  createDirectoryIfMissing True dir+  writeFile (dir </> "inline.env") "TOKEN=inline-haskell\n"+  let spec = object+        [ "project" .= object ["name" .= ("haskell-inline" :: Text)]+        , "providers" .= object ["env" .= ("dotenv://inline.env" :: Text)]+        , "profiles" .= object+            [ "default" .= object+                [ "secrets" .= object+                    [ "TOKEN" .= object+                        [ "description" .= ("token" :: Text)+                        , "providers" .= (["env"] :: [Text])+                        ]+                    ]+                ]+            ]+        ]+  resolved <- S.load (S.builder & S.withInlineSpec spec (T.pack dir) & S.withReason "inline test")+  let token = Map.lookup "TOKEN" (S.resolvedSecrets resolved) >>= S.get+  expect (token == Just "inline-haskell") "inline spec did not resolve TOKEN"  testMissingRequired :: IO () testMissingRequired = do