secretspec 0.19.1 → 0.20.0
raw patch · 3 files changed
+112/−28 lines, 3 files
Files
- secretspec.cabal +8/−8
- src/SecretSpec.hs +71/−20
- test/Main.hs +33/−0
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