|
|
@@ -1,30 +1,40 @@
|
|
|
-// v2.7.0 — caller identity.
|
|
|
-//
|
|
|
-// Until now the server knew only "the token was valid". It attached nothing to
|
|
|
-// the call, so every key holder was indistinguishable and access control was
|
|
|
-// impossible to express. This is the identity layer that per-project row- and
|
|
|
-// column-level policy is written against.
|
|
|
+// v2.7.0 — caller identity, resolved PER CALL.
|
|
|
//
|
|
|
// How a principal is decided, in order:
|
|
|
-// 1. the listener has auth and the token matched a NAMED key -> that name
|
|
|
-// 2. the listener has auth and the token matched a BARE key -> `unnamed`
|
|
|
-// 3. the listener has no auth processor at all -> `anonymous`
|
|
|
+// 1. the request carries a bearer token matching a NAMED key -> that name
|
|
|
+// 2. the request carries a token matching a BARE key -> `unnamed`
|
|
|
+// 3. the request carries no usable token -> `anonymous`
|
|
|
//
|
|
|
-// Case 3 is not an error. The default 127.0.0.1 plaintext listener has no auth,
|
|
|
-// and `smartbotic-db-cli` plus operator tooling ride on it. `anonymous` is a
|
|
|
-// real, grantable principal so local access is allowed by an explicit policy
|
|
|
+// Case 3 is not an error. The default 127.0.0.1 plaintext listener has no auth
|
|
|
+// and `smartbotic-db-cli` plus operator tooling ride on it, so `anonymous` is a
|
|
|
+// real, grantable principal: local access is allowed by an explicit policy
|
|
|
// rather than by an implicit hole. See
|
|
|
// docs/superpowers/specs/2026-08-08-rls-cls-design.md.
|
|
|
//
|
|
|
-// Transport: BearerAuthProcessor stores the name as an auth-context property.
|
|
|
-// gRPC gives no way to pass state from an AuthMetadataProcessor to a handler
|
|
|
-// other than the AuthContext, which is why the processor's `context` argument
|
|
|
-// (ignored since v2.4) is now used.
|
|
|
+// WHY NOT AuthContext
|
|
|
+// -------------------
|
|
|
+// The obvious implementation - have BearerAuthProcessor stamp the name into the
|
|
|
+// gRPC AuthContext and read it back in the handler - is UNSOUND, and was
|
|
|
+// actually shipped and caught by
|
|
|
+// tests/load_test/test_policy_enforcement.sh before it could be enabled.
|
|
|
+//
|
|
|
+// AuthContext properties belong to the CONNECTION, not the call. gRPC pools
|
|
|
+// channels by target address, so several clients using DIFFERENT tokens against
|
|
|
+// the same address can share one connection and every one of them observes
|
|
|
+// whichever principal was stamped first. In the reproducer, three clients with
|
|
|
+// three distinct named keys all resolved as `ops`, so every check that should
|
|
|
+// have denied passed instead.
|
|
|
+//
|
|
|
+// Misattributing one caller's identity to another is worse than having no
|
|
|
+// identity at all. So identity is resolved from the request's own
|
|
|
+// `authorization` metadata on every call, and the auth processor no longer
|
|
|
+// consumes that header - handlers need to see it.
|
|
|
|
|
|
#pragma once
|
|
|
|
|
|
#include <string>
|
|
|
#include <string_view>
|
|
|
+#include <vector>
|
|
|
|
|
|
namespace grpc {
|
|
|
class ServerContext;
|
|
|
@@ -32,12 +42,9 @@ class ServerContext;
|
|
|
|
|
|
namespace smartbotic::database::auth {
|
|
|
|
|
|
-// Auth-context property carrying the principal name.
|
|
|
-inline constexpr const char* kPrincipalProperty = "sbdb_principal";
|
|
|
-
|
|
|
// Reserved principal names. Rejected as key names in config, because a key
|
|
|
-// called "anonymous" would otherwise be indistinguishable from an unauthed
|
|
|
-// caller in a policy.
|
|
|
+// called "anonymous" would be indistinguishable from an unauthenticated caller
|
|
|
+// in a policy.
|
|
|
inline constexpr const char* kPrincipalAnonymous = "anonymous";
|
|
|
inline constexpr const char* kPrincipalUnnamed = "unnamed";
|
|
|
|
|
|
@@ -45,8 +52,32 @@ inline bool isReservedPrincipal(std::string_view name) {
|
|
|
return name == kPrincipalAnonymous || name == kPrincipalUnnamed;
|
|
|
}
|
|
|
|
|
|
-// Resolve the calling principal. Never throws and never returns empty: a
|
|
|
-// request with no identity is `anonymous`, which is a decision, not a failure.
|
|
|
-std::string principalOf(const grpc::ServerContext* context);
|
|
|
+// Maps a bearer token to the principal that owns it.
|
|
|
+//
|
|
|
+// Holds the union of every listener's keys, because one DatabaseGrpcImpl is
|
|
|
+// registered on all listeners and a request does not carry which listener it
|
|
|
+// arrived on. A token is only accepted at all if some listener's auth processor
|
|
|
+// accepted it, so the union cannot widen authentication - it only names what
|
|
|
+// was already authenticated.
|
|
|
+class PrincipalResolver {
|
|
|
+public:
|
|
|
+ struct NamedKey {
|
|
|
+ std::string name;
|
|
|
+ std::string key;
|
|
|
+ };
|
|
|
+
|
|
|
+ PrincipalResolver() = default;
|
|
|
+ explicit PrincipalResolver(std::vector<NamedKey> keys) : keys_(std::move(keys)) {}
|
|
|
+
|
|
|
+ void setKeys(std::vector<NamedKey> keys) { keys_ = std::move(keys); }
|
|
|
+ [[nodiscard]] bool empty() const noexcept { return keys_.empty(); }
|
|
|
+
|
|
|
+ // Never throws, never returns empty: no identity means `anonymous`, which is
|
|
|
+ // a decision rather than a failure.
|
|
|
+ [[nodiscard]] std::string resolve(const grpc::ServerContext* context) const;
|
|
|
+
|
|
|
+private:
|
|
|
+ std::vector<NamedKey> keys_;
|
|
|
+};
|
|
|
|
|
|
} // namespace smartbotic::database::auth
|