-
Notifications
You must be signed in to change notification settings - Fork 217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Generic Custom Pre-Processing Traits & Caller Identity Modeling? #2177
Comments
Thanks for this detailed writeup!
I think this actually dovetails into another problem we've been kicking around for a while that we're currently working on heavily: document schemas. Bear with me. You mention yourself that context is treated as an unmodeled bag of data, difficult to work with largely because you need to assemble the concrete representation yourself from it. This is essentially the same situation we find ourselves in with documents. Documents sometimes have defined schemas, and we therefore want a way to be able to easily construct concrete types from them. I've actually got a working, hand-written example of this in Python. Here's an example from the tests: def test_doc_to_shape():
document = Document(
{
"birdId": "123456",
"location": {
"latitude": "52.5163",
"longitude": "13.3777",
},
"notes": "Has black spots in the grey hood area.",
}
)
shape = CreateSightingInput(
bird_id="123456",
location=Location(latitude="52.5163", longitude="13.3777"),
notes="Has black spots in the grey hood area.",
)
assert document.as_shape(CreateSightingInput) == shape So if I were using a Python server implementation, I could do something like: @dataclass(kw_only=True)
class AuthContext:
id: str
def serialize(serializer: ShapeSerializer): ...
def deserialize(deserializer: ShapeDeserializer): ...
class BirdWatcherService:
async def create_sighting(input: CreateSightingInput, context: Document):
id = context["auth"].as_shape(AuthContext).id
... In this case the // Marked as internal so as to not be generated into clients.
@internal
structure AuthContext {
id: String
} What's critical here is that that You still of course need your middleware to take from the input, perform any necessary validations / transformations, and then populate the context. But with this you would be able to ensure that your context is consistently structured: async def auth_middleware(
request: HTTPRequest,
context: Document,
next: Callable[[HTTPRequest, Document], Any]
) -> None:
id: str = await validate_auth(request.headers["Authorization"])
context["auth"] = Document.from_shape(AuthContext(id=id))
next(request, context) All this of course relies on the ability to have shapes in the model not connected to an operation, which is something we will be adding. It also requires a pretty hefty refactor of the way clients and servers work, but that's also something we want to do where possible. I'm working on this right now for smithy-python, which has the advantage of not having been made GA yet. To address some of your assumptions:
Essentially what I've outlined above is a strategy to bring any-type data into a similar realm of reliability as normally structured data.i
Yes. Auth is expected to change, and to do so as backwards compatibly as possible. Having to change the code for every operation and operation invocation would be a major hassle. Comparatively it's often possible to simply smooth over the config changes on the client side. On the server side, the middleware can similarly present the necessary information (e.g. customer id) in a consistent way despite the auth type having different inputs.
With the strategy outlined above, you don't need a trait, just some shapes. They can be made |
Just to clarify my understanding, it sounds like the hope is to:
I think this covers the case of stuff that should stay outside of modeled inputs (i.e. in contexts) yet still be typed. These are now partially modeled, but the Smithy model itself gives no hints as to what inputs or outputs they complement. This, however, still leaves us with modeled inputs being restricted to what default deserialization can handle (i.e. basically 1:1 payload field to struct field de/serialization or validation. Can't map multiple payload fields into a single struct field or complex protocol-specific field transformations). The built-in default deserialization that comes with a Smithy server SDK (and what I imagine most people will stick with) still feels closed/non-extensible instead of open/extensible. Suppose a Smithy Java server code generator can auto-generate routing and modeled input de/serialization code for Apache Tomcat as the HTTP server (may sound familiar to Builder Tools). If we want to add a small transformation for a specific field, we have to completely replace the default deserializer with our own or use a mutating middleware instead of just registering a small function the default deserializer can defer to when handling this special field. If we go heavy on the Java dependency injection paradigm, I was imagining something like this (with totally illegal syntax for compactness, not that this is very compact): import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
// Generated by Smithy Java server code generator.
class MyInput {
@Inject
@Named("MyInput.fieldA")
final String fieldA;
@Inject
@Named("MyInput.fieldB")
final int fieldB;
}
// Generated by Smithy Java server code generator.
abstract class SmithyDefaultMyInputRestJson1Providers {
private final ObjectMapper objectMapper = new ObjectMapper();
// Request body is a UTF-8 JSON string. Make it a dynamic tree first.
//
// Something like a Jackson JsonNode that can represent trees
// of basic primitive (e.g. boolean, int, string) or collection types
// (e.g. map, list) that most serialization formats support.
//
// Smithy's document type is kind of this already, so the Smithy Java
// server SDK could implement something with similar behavior. Just using
// Jackson JsonNode for convenience here.
//
// Dynamically typed languages can use the language-native collection
// types instead like Python (dict, list) or Clojure (map, vector).
//
// For example, Python would use json.loads() and browse a dict.
// Clojure would use clojure.data.json/read-str and browse a map.
//
// Statically typed languages would have an extra step that maps
// these generic nested collections onto a nominal type (since most
// statically typed languages besides OCaml, Scala, and TypeScript
// don't support structural types).
//
// This 2-shot deserialization process that first translates to
// generic collections provides hooks for custom field-level
// deserialization in an ergonomic manner.
@Provides
@Singleton
@Named("jsonBody")
@Protocols("aws.restJson1")
JsonNode provideJsonBody(final SmithyHttpRequest request) {
return objectMapper.readTree(
StandardCharsets.UTF_8.decode(request.body()).toString());
}
// Default field A deserializer.
@Provides
@Singleton
@Named("MyInput.fieldA")
@Protocols("aws.restJson1")
String provideFieldA(@Named("jsonBody") final JsonNode jsonBody) {
return jsonBody.findPath("fieldA").asText();
}
// Default field B deserializer. This has the custom deserialization trait
// on the Smithy model, so the user must provide the implementation.
@Provides
@Singleton
@Named("MyInput.fieldB")
@Protocols("aws.restJson1")
abstract int provideFieldB(@Named("jsonBody") final JsonNode jsonBody);
}
class CustomMyInputRestJson1Providers extends SmithyDefaultMyInputRestJson1Providers {
// Use default deserializer for field A.
// The Smithy Java server framework's default deserializer is good enough here.
// Provide custom deserializer for field B.
@Provides
@Singleton
@Named("MyInput.fieldB")
@Protocols("aws.restJson1")
int providefieldB(@Named("jsonBody") final JsonNode jsonBody) {
final int originalFieldB = jsonBody.findPath("fieldB").asInt();
return originalFieldB * 100;
}
}
Any validation logic can be applied after these field-level deserializers are run to generate the composite structure. Smithy traits already inherently have an extract-transform-load (ETL) ordering to them. For example, In that sense, maybe this single trait I'm proposing is actually 3:
The existing de/serialization, constraint, and type refinement traits are just special cases of these. As for the maintenance benefit of leaving auth outside of modeled inputs, this one isn't as clear to me. Is the concern having to manually maintain caller identity members and traits on every operation's input shape? For example: resource User {
identifiers: {
userId: string
}
// ...
}
@input
@references([
{resource: User}
])
structure Operation_1_Input {
@required
userId: String
// ...
}
// ...
// We've repeated @references, @required, and the field 100 times now.
@input
@references([
{resource: User}
])
structure Operation_100_Input {
@required
userId: String
// ...
} If we, for example, need to add a trait like // --------------------
// Structure approach.
// --------------------
@references([
{resource: User}
])
structure CallingUser {
@required
// Any other traits.
userId: String
}
@input
structure Operation_1_Input {
@required
callingUser: CallingUser
// ...
}
// --------------------
// Mixin approach.
// --------------------
@mixin
@references([
{resource: User}
])
structure BaseInputAuth {
// This is a bit nasty if certain operations have optional auth.
@required
// Any other traits.
userId: String
}
@input
// Might cause problems if the operation input shape also applies @references
// directly since, according to the mixin docs, that takes precedence over
// traits inherited from a mixin.
//
// Even if it didn't get overwritten and just became multiple mixin applications,
// multiple declarations of the same mixin doesn't follow a last-writer-wins
// or union behavior. Last-writer-wins is particularly undesirable since mixin
// application order matters then.
@references([
{resource: User}
])
structure Operation_1_Input with [BaseInputAuth] {
// ...
} I can still see some complaints about this being more verbose than desirable. The Basically, it's whether auth should be opt-out by default (auth trait on a service to swap to opt-in by default, |
Revisiting this after awhile, so I might have forgotten some context.
I think we're both on the same page that the caller ID/details presented to the business logic (e.g. AWS account ID, IAM unique ID) should stay the same regardless of the actual underlying auth protocol (e.g. AWS SigV2, AWS SigV4). In that case, isn't there an argument for making the caller ID/details an explicit property on operation input and output shapes instead of a sideband/context thing since it's a protocol-agnostic property? For example, it doesn't seem strange for a resource to have an owner property that's equal to the protocol-agnostic user ID. This is something AWS has done implicitly by making all resources have an ARN which usually includes an AWS account ID (with a few exceptions like S3 buckets and some EC2 resources). Therefore, ownership information is mixed into the name/ID property in AWS. So now that it's in the input/output, we need some way of hooking into the default de/serializers used by the client/server code generators to provide protocol-specific helpers to, for example, extract an identity from an HTTP If we don't have that, then we're stuck with writing our own de/serializers from scratch to replace the default ones (ensures this protocol-specific stuff stays outside of middlewares) or adding mutating middlewares (not that nice). |
Overview
Would it make sense to add a generic custom pre-processing trait like
@refine(functionId: String | Enum, protocols: List[shapeId])
to the core library? This can signal server stub code generators to make users register pre-processing functions which extend their built-in deserialization systems.From a UX perspective, this lets users skip writing their own Smithy traits and the supporting code generator logic for custom type refinement and constraint traits. For users not comfortable with writing or extending code generators, this eliminates a lot of friction (kind of an extension of #117).
Background
Today, Smithy provides various type refinement and constraint traits in its core library. These traits are effectively a set of standard pre-processing directives. For example:
@default
@required
@jsonName
@httpHeader
To provide a batteries-included experience, server stub code generators can augment built-in deserialization mechanisms to automatically handle these. Support across all code generators will generally be strongest for core library traits and weaker for custom traits (e.g. framework-specific).
Unmodeled information, however, has a rougher experience.
For unmodeled traits or ones unsupported by the underyling code generator, extra validation is done in the middleware or handler layers instead. There's no guarantee that the code generator tells you it doesn't support a trait either. It might just silently ignore it.
For unmodeled members, the Smithy TypeScript and Rust server SDKs provide escape hatches for propagating unmodeled properties into the code-generated server stubs. The TypeScript server SDK has contexts while the Rust server SDK has extensions.
The data flows for the Smithy TypeScript and Rust server SDKs look like this today:
Effectively, modeled inputs are generally restricted to what default deserialization can handle while everything else is generally placed in unmodeled inputs.
Context-styled escape hatches are fine if unmodeled information is a non-essential input to a service operation (e.g. observability) but introduces room for a lot of subtle bugs if the information is essential (e.g. caller identity). An extreme example is Go's
context
library which many misuse as an untyped map to pass both essential and non-essential information through their programs.Bringing more info into Smithy models lets code generators enforce more complete modeled inputs before propagating them to middlewares and handlers.
Auth Example
To paint a clearer picture, lets consider auth.
For services with auth, operations are a function of the caller identity (e.g.
deleteSong(userId, songId): deleteSongOutput
if I destructuredeleteSongInput
into 2 arguments). Since identities are a formal resource in the service (e.g. AWS IAM identities like roles and users), it seems reasonable to model them like this in Smithy:In some (many?) auth schemes, however, the caller identity may not be given as a field that should be provided as-is in code-generated server stubs. Instead, it may need to be pre-processed into a usable form.
For HTTP specifically, the
Authorization
header may not be something that's useful to directly map onto a member in modeled input types. In fact, Smithy strongly discourages this in the@httpHeader
trait documentation.For example, AWS service requests use the AWS SIGv4 authN scheme. The AWS SIGv4 HTTP
Authentication
header documentation provides this example header:An authZ middleware or business logic inside code-generated server stub implementations, however, probably instead only wants the AWS IAM unique ID (e.g.
AKIAIOSFODNN7EXAMPLE
) or an AWS IAM identity ARN (e.g.arn:aws:iam::111122223333:role/role-name
) instead of the rawAuthorization
header.Since the AWS IAM authZ APIs aren't public, lets use the Amazon Verified Permissions
IsAuthorized
API and AWS IAM Policy SimulatorSimulateCustomPolicy
API as examples. Both of these APIs generally are something likeisAuthorized(callerIdentity, resource, operation, identityBasedPolicy, resourceBasedPolicy): boolean
wherecallerIdentity
isn't the rawAuthorization
header.Likewise at the business logic layer, we may have a
ListResources
API which does a database query with a filter using acallerIdentity
that isn't the rawAuthorization
header.In essence, it can be useful to enforce some member transformation before finalizing modeled inputs for handoff to middlewares and business logic.
There's 2 ways I can think of doing this while keeping caller identity information in the modeled input with today's Smithy server SDKs.
Bind the
Authorization
HTTP Header Anyways + Use Mutating MiddlewareOne option is to ignore the
@httpLabel
guidance and bind theAuthorization
header anyways.Then we introduce a mutating middleware that mutates the modeled input (e.g.
DeleteSongInput
) before passing it to subsequent middlewares and business logic.This, however, introduces 2 subtle semantics.
First is that we've introduced an implicit dependency between middlewares because we're modifying the modeled input. The authZ middleware may not work if the caller identity mutation middleware isn't placed before it. If the server framework tries to run middlewares concurrently as a performance optimization, this middleware dependency information isn't automatically known to the framework. More abstractly, this approach has introduced mutability which allows a swath of bugs.
Second is that we've accidentally added a protocol-specific middleware in a place that should probably be protocol-agnostic. For a multi-protocol endpoint, other protocols may map a different value onto the modeled input member. The caller identity mutation middleware could get an HTTP
Authorization
header string or something else and must handle all cases correctly.Use Contexts as a Side-Channel + Use Mutating Middleware
Another option is to just default to some empty/placeholder value for the modeled input member, add the value to a context object, then use a middleware to transfer the value from the context object to the modeled input.
This is better than the other approach because the protocol-specific context creator can ensure the unmodeled input value it extracted is protocol-agnostic.
We still have the same mutation semantic though so we still have room for subtle ordering and concurrency bugs.
Wish List
Essentially, we'd like some standard trait respected across server code generators that lets users extend built-in deserialization in a safe and easy manner. This reduces the number of situations requiring less safe unmodeled input logic and middlewares that mutate modeled or unmodeled inputs.
With a generic custom pre-processing trait, we could do something like this in our Smithy models:
A server stub code generator could then generate this:
Users would then register their service and refiners with the HTTP server implementation.
This is probably a horrible sample implementation for many reasons but hopefully it gets the idea across.
A more robust solution might look something like Java's JSR-330 dependency injection annotations (
@Inject
,@Named
) and how Dagger 2 uses them. Dagger lets you define a dependency graph of value providers based on explicit@Named
annotations and type inference. It then uses code generation at build time to create the dependency injection wiring logic.Assumptions to Question
All the previous points are based on these premises/assumptions:
1 is primarily an implementation detail so safety is highly dependent on how well the code generators are designed.
2 is probably where opinions will differ. Should all auth and caller identity related things be encapsulated in authentication traits and passed separately from modeled inputs? What effects does this have on the readability of Smithy models or client code generation (can we still easily pass caller identity information separate from modeled inputs like the current AWS SDKs)? Am I just splitting hairs about whether to put some info in one function argument versus another?
3 it's not clear how client generators are also expected to interpret this trait. It seems like it might be too server-centric though
@clientOptional
exists. Also if this is applied to an output shape, is this a signal to have a client side refiner for raw response to modeled output deserialization? In that case, the trait is anisotropic in a weird way that has different meanings between input and output shapes.The text was updated successfully, but these errors were encountered: