Skip to content
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

[NEW] Custom authentication via Modules #11256

Open
KarthikSubbarao opened this issue Sep 9, 2022 · 11 comments
Open

[NEW] Custom authentication via Modules #11256

KarthikSubbarao opened this issue Sep 9, 2022 · 11 comments

Comments

@KarthikSubbarao
Copy link
Contributor

The problem/use-case that the feature addresses

In Redis, we have password based authentication for Redis Users. Only supporting this mode of authentication is limiting, as applications might require other variations of authentication.

Description of the feature

This issue is about adding new custom auth functionality allowing Redis Modules to implement their own auth logic. And this will be by registering hooks and extending the Authentication flow so that AUTH and HELLO commands are intercepted through these custom hooks. We are introducing two new Module APIs - RM_RegisterAuthCallback and RM_DenyAuth.

Some of the main requirements for the feature are:
  • Use Module based custom authentication when AUTH and HELLO commands (with the AUTH sub commands) are used.
  • Allow custom auth functionality to be provided by multiple modules at a given time.
  • Allow the same Module to provide multiple custom auth hooks at a given time.
  • Support both blocking and non blocking implementations for custom auth.
  • Allow custom auth to be attempted for all Redis users except those disabled or having a nopass flag. (Following the same behavior for the current password based auth)

RegisterAuthCallback API

int RM_RegisterAuthCallback(RedisModuleCtx *ctx, RedisModuleAuthCallback cb)
typedef void (*RedisModuleAuthCallback)(struct RedisModuleCtx *ctx, RedisModuleString *username, RedisModuleString *password);
Modules will use this API to register a custom auth callback by providing a function which accepts a RM_Context, username, and password as args. Internally this API will create a new CustomAuthContext that contains the callback and the module that providing the callback - this context is added to a linked list of custom auth contexts. Once registered, when the AUTH or HELLO with the AUTH sub command is used, Module based authentication is attempted.

The same Module is allowed to register multiple Auth callbacks. And multiple Modules are allowed to register Auth callbacks at a given time.

Custom Auth

The existing auth flow goes through ACLAuthenticateUser which currently validates using password based authentication. Now, this is extended to also authenticate via module / custom auth callbacks. The username and password are provided to the callbacks and the Module can handle this appropriately - either authenticating, denying auth, or neither (skipping).

Inside ACLAuthenticateUser, we will first attempt Module based Authentication to allow the Module to decide whether the client should be authenticated / denied auth. In case that all Modules “skip” by not authenticating / denying auth, we will attempt password based auth.
Custom Auth OK ERR Skipped Skipped
Password-based Auth Not attempted Not attempted OK ERR
Result OK ERR OK ERR

Custom auth will be attempted for all Redis users (created through the ACL SETUSER cmd or through Module APIs) provided that they are not disabled AND they do not have the nopass flag. When a user has the nopass flag, custom auth will always succeed, just like in the case of password based auth.

For the AUTH command, we will support both variations - AUTH <username> <password> and AUTH <password>. In case of the AUTH <password> variation, the custom auth callbacks are triggered with “default” as the username and password as what is provided.

When module / custom auth is attempted, we iterate through the linked list of all the auth callbacks registered and attempt custom auth serially until one of the callbacks either authenticates (using the Module Auth APIs) OR denies authentication using a new API explained below. If the module wants to allow the engine to attempt other custom auth callbacks, they do should not use either API. If all the modules ignore the callback, password based auth is attempted and this will be used to decide whether AUTH succeeded or failed.

Deny Auth API

int RM_DenyAuth(RedisModuleCtx *ctx, const char *err)
Modules can use this API to return an error during authentication. It will cancel the client’s authentication process and break the Auth chain (if one exists) by returning an error to the client with a custom error message. The error message will be immediately added as an error reply to the client. If no custom error message is provided, the standard auth error message is added as an error reply.

This API can only be used when custom auth is in progress and will only return an ERR for the AUTH/HELLO command that is in progress. For all future commands, the Module will need to use the DenyAuth API once again to reject the command.

Implementing a blocking behavior

The Module can implement a blocking behavior from the custom auth callbacks. When the callback is triggered, they can use RM_BlockClient and start a background job. And when the background job finishes, they can call the RM_UnblockClient API and utilize the blocked client reply callback to either authenticate (using RM_AuthenticateClient...) or deny auth (using RM_DenyAuth) or skip. Because of this reason, the custom auth callback is a void function. When the callback is triggered, in case of a blocking implementation, we will not know whether auth succeeded or failed as auth is still in progress. The blocked client reply callback is used for this purpose.

Expected behavior of Modules

  • In order to authenticate the client for custom auth, Modules should use the RedisModule_AuthenticateClientWithUser API or the RedisModule_AuthenticateClientWithACLUser API.
  • In case of denying auth for a client, Modules should use the RedisModule_DenyAuth AND should use the RedisModule_ACLAddLogEntry API to add to failure logs.
  • As part of the custom auth callbacks / blocking reply callbacks (for both the AUTH and HELLO commands), the modules should not reply to the client as this will be done by the engine based on success / error.
  • In case of the HELLO command’s (containing auth) setname variation, the engine will set the name of the client in the success case. Modules should not use the setname API - unless they have a specific use case other than setting this as part of the HELLO command.

If this high level design / approach sounds good, I will submit a PR

@madolson
Copy link
Contributor

@yossigo FYI, as this is a continuation of our older discussion about a better module auth.

I'm not sure how I feel about overloading the the RedisModule_BlockClient() API, since the unblock API has a callback that includes the argv/argc of the client. I think we should introduce a new blocking API just for the auth, so that the callback can correctly pass in the username and password.

I'm also not convinced about the RedisModule_DenyAuth approach. It does seem like RedisModuleAuthCallback could return whether or not the authentication was successful after setting the user. We could update the API to:

typedef int (*RedisModuleAuthCallback)(struct RedisModuleCtx *ctx, RedisModuleString *username, RedisModuleString *password, RedisModuleString **err);

So that you could pass back an error string representing the custom error message. Then have the explicit three value response:

  • Auth successful
  • Auth unsuccessful
  • Auth not-handled

I thin having the explicit 3 responses is likely the most developer friendly to understand. I think the rest of it looks good.

@zuiderkwast
Copy link
Contributor

zuiderkwast commented Sep 11, 2022

I agree with @madolson that returning the auth result from the auth callback is the most intuitive for the common case when you don't need to block the client.

However, I think we need a 4th response value "auth blocked" (or "no result yet") for the case when we need to block the client before we can return success/fail/skip.

I think it's a good idea to reuse the BlockClient mechanism, at least internally. We can introduce a new blocked type "blocked for auth".

@KarthikSubbarao What do you suggest to use for reply_callback and timeout_callback for RedisModule_BlockClient()? As Madelyn pointed out, it's not optimal that the reply callback gets passed the argv of the client, i.e. the HELLO or AUTH command with username and password, but OTOH it's no disaster either.

I have idea how to encapsulate this: We allow the auth callback to return a value indicating that the client shall be blocked (e.g. REDISMODULE_AUTH_BLOCK) and then the client is automatically blocked with an internal reply callback which is not exposed to the module author. To unblock, the module author calls a special auth-unblock API, something like RedisModule_UnblockClientBlockedForAuth(*client, result, **err), where result is grant/deny/skip.

@madolson
Copy link
Contributor

I have idea how to encapsulate this: We allow the auth callback to return a value indicating that the client shall be blocked (e.g. REDISMODULE_AUTH_BLOCK) and then the client is automatically blocked with an internal reply callback which is not exposed to the module author. To unblock, the module author calls a special auth-unblock API, something like RedisModule_UnblockClientBlockedForAuth(*client, result, **err), where result is grant/deny/skip.

They would also need to supply the user to be authenticated. The current blocking system assumes that unblocking APIs are called on background threads, so we don't have access to main thread functions such as looking up users, so I'm not sure how this would work.

The common case when you don't need to block the client.

I think this will actually be a fairly common case. Some people might want to override the auth with a kerberos call or something, but I think it's also likely people will federate to something like active directory or vault, which require offbox API calls.

@KarthikSubbarao
Copy link
Contributor Author

KarthikSubbarao commented Sep 11, 2022

@yossigo FYI, as this is a continuation of our older discussion about a better module auth.

I'm not sure how I feel about overloading the the RedisModule_BlockClient() API, since the unblock API has a callback that includes the argv/argc of the client. I think we should introduce a new blocking API just for the auth, so that the callback can correctly pass in the username and password.

I agree that it makes the Module's usage simpler if the blocking callback can provide the username and password as args.

But, in the current design, I imagined that the Module will first obtain the username and password through the AuthCallback (registered via the RM_RegisterAuthCallback API) when custom authentication is attempted. If the Module decides to use a blocking implementation, it can use RM_BlockClient. Later, when the background job completes, it can call RM_UnblockClient by providing private data that has everything it needs - example - Authentication result, username and password. With this, when the blocking reply callback is triggered, the Module can just use RedisModule_GetBlockedClientPrivateData API and it has access to everything it needs.

In this design, I tried to reuse the existing blocking interface as much as possible. Because if we create a new BlockClientOnAuth API, we would need to create and provide a new BlockedAuthReply callback and a TimedOutAuthReply callback both which instead of client args have the username and password as args. If this is done just to obtain the username and password as args, maybe the RedisModule_GetBlockedClientPrivateData API approach could be considered instead.

What do you think about using RedisModule_GetBlockedClientPrivateData?

I'm also not convinced about the RedisModule_DenyAuth approach. It does seem like RedisModuleAuthCallback could return whether or not the authentication was successful after setting the user. We could update the API to:

typedef int (*RedisModuleAuthCallback)(struct RedisModuleCtx *ctx, RedisModuleString *username, RedisModuleString *password, RedisModuleString **err);

So that you could pass back an error string representing the custom error message. Then have the explicit three value response:

  • Auth successful
  • Auth unsuccessful
  • Auth not-handled

I thin having the explicit 3 responses is likely the most developer friendly to understand. I think the rest of it looks good.

However, if we want to have different return codes rather than using the RM_DenyAuth API approach, then I think we should create a new BlockClientOnAuth API and also create the two BlockedAuthReply callback and TimedOutAuthReply callback that will return one of the codes above and also have the username and password as function arguments. If we choose this approach, it might make sense to add a 4th code for "Auth blocked" as @zuiderkwast suggested.

The concern I have with adding return codes is that Modules can return "Auth successful" but forget to the AuthenticateUser Module API. Similarly, we have the other case where the Module can return "Auth unsuccessful" and still use the AuthenticateUser Module API. I initially thought it was better to have a single place of deciding whether Auth succeeded - return code or the Module API.
If we are adding return codes (example - Auth successful), are we still expecting Modules to use the AuthenticateUser Module API?

@yossigo
Copy link
Member

yossigo commented Sep 11, 2022

I'm not very happy with the current blocking API if used with its callbacks. It's not intuitive and involves too many edge cases. So, using a callback return value to block and a dedicated auth-unblock makes sense. Still, we'll need to consider all special cases - who is responsible for timeouts, dropped client handling, cancellation, etc.

@zuiderkwast
Copy link
Contributor

zuiderkwast commented Sep 12, 2022

RedisModule_UnblockClientBlockedForAuth(*client, result, **err), where result is grant/deny/skip.

They would also need to supply the user to be authenticated. The current blocking system assumes that unblocking APIs are called on background threads, so we don't have access to main thread functions such as looking up users, so I'm not sure how this would work.

@madolson Can't UnblockClientBlockedForAuth use a pthread mutex (like moduleUnblockClientByHandle already does) and then do the necessary lookup from client to user, etc. We can store the user, client, etc. in the CustomAuthContext that this feature introduces, right? The privdata mechanism can be used for passing stuff like the auth result. What am I missing?

What do you think about using RedisModule_GetBlockedClientPrivateData?

@KarthikSubbarao sounds good

are we still expecting Modules to use the AuthenticateUser Module API?

@KarthikSubbarao No, I think the auth mechanism can just make a judgement grant/deny/skip based on username and password. AuthenticateUser is for a different purpose IUCC.

@madolson
Copy link
Contributor

madolson commented Sep 12, 2022

Can't UnblockClientBlockedForAuth use a pthread mutex (like moduleUnblockClientByHandle already does) and then do the necessary lookup from client to user, etc. We can store the user, client, etc. in the CustomAuthContext that this feature introduces, right? The privdata mechanism can be used for passing stuff like the auth result. What am I missing?

I didn't follow this, can you provide a concrete flow? How are we handling the edge cases that Yossi mentioned, like timeouts? How do we notify the module that there is pending work to cleanup? How are we supporting the flow where we are returning an ACL user?

No, I think the auth mechanism can just make a judgement grant/deny/skip based on username and password. AuthenticateUser is for a different purpose IUCC.

The current code supports the username not being the same as the existing ACL username. It seems like you are proposing something else entirely.

@madolson
Copy link
Contributor

If we are adding return codes (example - Auth successful), are we still expecting Modules to use the AuthenticateUser Module API?

We can add some basic sanity checks here to make sure the APIs were implemented correctly. Generally we expect modules to be implemented correctly, since we have no real defense against them doing things wrong.

@zuiderkwast
Copy link
Contributor

It seems like you are proposing something else entirely.

@madolson Sorry for my uninformed ideas. 😳 The flow I had in mind only works for existing ACL users. (In my mind, a 'success' return from the auth callback would correspond to an implicit RedisModule_AuthenticateClientWithACLUser. I didn't think about edge cases with timeout, etc.) Perhaps this confused me: "Allow custom auth to be attempted for all Redis users except those disabled or having a nopass flag. (Following the same behavior for the current password based auth)". But we want the auth callback to be called also for non-existing and disabled users. (Right?)

I guess I agree the module should call RedisModule_AuthenticateClientWith[ACL]User then. RM_DenyAuth still feels odd though. How about denying a user either by returning 'deny' from the auth callback or, if the client is blocked, by unblocking it without first authenticating it?

@KarthikSubbarao
Copy link
Contributor Author

To help clarify what I initially said, this is sample Module code for the original design proposed. It explains how the module can use the APIs to implement blocking custom authentication.

Here, to authenticate, they need to use the Auth Module API (example - RedisModule_AuthenticateClientWithACLUser).
To deny authentication and return an error immediately, they can call the RM_DenyAuth API. To skip / allow the engine to attempt other callbacks, the Module should not use either API.

To have access to the username and password later on in the blocking reply callback, the Module should use RedisModule_Alloc and later on obtain the data using RedisModule_GetBlockedClientPrivateData.

The main advantage in this approach is it reuses a lot of the Module blocking APIs. However, it does need a new API - RM_DenyAuth which has specific use case just for returning an error during custom authentication.

/*
 * The thread entry point that actually executes the blocking part of the AUTH command.
 * This function sleeps for 2 seconds and then unblocks the client which will later call
 * `AuthBlock_Reply`.
 * `arg` is expected to contain the RedisModuleBlockedClient, username, and password.
 */
void *AuthBlock_ThreadMain(void *arg) {
    sleep(2);
    void **targ = arg;
    RedisModuleBlockedClient *bc = targ[0];
    int result = 2;
    const char* user = RedisModule_StringPtrLen(targ[1], NULL);
    const char* pwd = RedisModule_StringPtrLen(targ[2], NULL);
    if (!strcmp(user,"foo") && !strcmp(pwd,"allow")) {
        result = 1;
    }
    else if (!strcmp(user,"foo") && !strcmp(pwd,"deny")) {
        result = 0;
    }
    // Provide the result, username, password to the blocking reply cb.
    void **replyarg = RedisModule_Alloc(sizeof(void*)*3);
    replyarg[0] = (void *) result;
    replyarg[1] = targ[1];
    replyarg[2] = targ[2];
    RedisModule_Free(targ);
    RedisModule_UnblockClient(bc,replyarg);
    return NULL;
}

/*
 * Reply callback for a blocking AUTH command. This is called when the client is unblocked.
 * Note: The return code here does not matter for custom authentication. We just need to use
 * the Auth / DenyAuth API correctly.
 */
int AuthBlock_Reply(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    REDISMODULE_NOT_USED(argv);
    REDISMODULE_NOT_USED(argc);
    void **targ = RedisModule_GetBlockedClientPrivateData(ctx);
    int result = targ[0];
    size_t userlen = 0;
    const char* user = RedisModule_StringPtrLen(targ[1], &userlen);
    const char* pwd = RedisModule_StringPtrLen(targ[2], NULL);
    // Handle the success case by authenticating.
    if (result == 1) {
        RedisModule_AuthenticateClientWithACLUser(ctx, user, userlen, NULL, NULL, NULL);
        return REDISMODULE_OK;
    }
    // Handle the Error case by denying auth
    else if (result == 0) {
        RedisModule_DenyAuth(ctx, "Auth Denied from Module");
        return REDISMODULE_ERR;
    }
    // "Skip" Authentication
    return REDISMODULE_OK;
}

/* Private data freeing callback for custom auths. */
void AuthBlock_FreeData(RedisModuleCtx *ctx, void *privdata) {
    REDISMODULE_NOT_USED(ctx);
    void **targ = privdata;
    // Free the username and password.
    RedisModule_FreeString(NULL, targ[1]);
    RedisModule_FreeString(NULL, targ[2]);
    RedisModule_Free(privdata);
}

/* Callback triggered when the engine attempts custom auth
 * The Module can have auth succeed / denied here itself, but this is an example
 * of blocking custom auth.
 */
void blocking_auth_cb(RedisModuleCtx *ctx, RedisModuleString *username, RedisModuleString *password) {
    REDISMODULE_NOT_USED(username);
    REDISMODULE_NOT_USED(password);
    pthread_t tid;
    // Block the client from the Module. We can optionally add a timeout callback here.
    RedisModuleBlockedClient *bc = RedisModule_BlockClient(ctx, AuthBlock_Reply, NULL, AuthBlock_FreeData, 0);
    // Allocate memory for the information needed.
    void **targ = RedisModule_Alloc(sizeof(void*)*3);
    targ[0] = bc;
    targ[1] = RedisModule_CreateStringFromString(NULL, username);
    targ[2] = RedisModule_CreateStringFromString(NULL, password);
    // Create a bg thread and pass the blockedclient, username and password to it.
    if (pthread_create(&tid, NULL, AuthBlock_ThreadMain, targ) != 0) {
        RedisModule_AbortBlock(bc);
    }
}

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    REDISMODULE_NOT_USED(argv);
    REDISMODULE_NOT_USED(argc);
    if (RedisModule_Init(ctx,"misc",1,REDISMODULE_APIVER_1)== REDISMODULE_ERR)
        return REDISMODULE_ERR;
    RedisModule_RegisterAuthCallback(ctx, blocking_auth_cb);
    return REDISMODULE_OK;
}

@KarthikSubbarao
Copy link
Contributor Author

KarthikSubbarao commented Sep 14, 2022

Based on the comments added, this is some sample Module code for implementing blocking custom authentication using the "return code" approach.

Here, both the custom auth callback and the blocking custom auth callback are provided with the username and password as function parameters. Also, both these callbacks are expected to return one of 4 codes: Auth Succeeded, Auth Denied, Auth Not Handled, Auth Blocked.
However, to authenticate, the Module should still use the RedisModule_AuthenticateClientWith[ACL]User API.

Timeouts for blocking custom authentications could just be handled in the Module if needed rather than relying on an engine timed out callback.

The main advantage in this approach is that Module’s usage of custom auth is clearer with the return codes. Also, the username/password are provided directly to the custom auth callback AND in the blocking custom auth callback, so the Module does not have to provide this to the blocking callback as privdata.

The drawback in this approach is I needed to create a new BlockClientOnAuth API so that we can block a client on Auth while providing callbacks which suit custom auth better - having return codes and having username/password/err as args. This is not entirely a drawback, but I felt we could have reused the existing BlockClient API.

We allow the auth callback to return a value indicating that the client shall be blocked (e.g. REDISMODULE_AUTH_BLOCK) and then the client is automatically blocked with an internal reply callback which is not exposed to the module author.

Two things that made this hard to implement are (1) We need to unblock the client and this requires a BlockedClientContext (2) To authenticate the client, we need to use the RedisModule_AuthenticateClientWith[ACL]User API which needs the RedisModuleContext and this is better done from a blocking reply callback in the Module.

Alternatively, if there are any ideas for blocking the client based on the return code and also being able to unblock the client, I can use them in updating this sample code.

/*
 * The thread entry point that actually executes the blocking part of the AUTH command.
 * This function sleeps for 2 seconds and then unblocks the client which will later call
 * `AuthBlock_Reply`.
 * `arg` is expected to contain the RedisModuleBlockedClient, username, and password.
 */
void *AuthBlock_ThreadMain(void *arg) {
    sleep(2);
    void **targ = arg;
    RedisModuleBlockedClient *bc = targ[0];
    int result = 2;
    const char* user = RedisModule_StringPtrLen(targ[1], NULL);
    const char* pwd = RedisModule_StringPtrLen(targ[2], NULL);
    if (!strcmp(user,"foo") && !strcmp(pwd,"allow")) {
        result = 1;
    }
    else if (!strcmp(user,"foo") && !strcmp(pwd,"deny")) {
        result = 0;
    }
    // Provide the result to the blocking reply cb.
    void **replyarg = RedisModule_Alloc(sizeof(void*));
    replyarg[0] = (void *) result;
    // Free the username and password and thread / arg data.
    RedisModule_FreeString(NULL, targ[1]);
    RedisModule_FreeString(NULL, targ[2]);
    RedisModule_Free(targ);
    RedisModule_UnblockClient(bc, replyarg);
    return NULL;
}

/*
 * Reply callback for a blocking AUTH command. This is called when the client is unblocked.
 */
int AuthBlock_Reply(RedisModuleCtx *ctx, RedisModuleString *username, RedisModuleString *password, const char **err) {
    void **targ = RedisModule_GetBlockedClientPrivateData(ctx);
    int result = targ[0];
    size_t userlen = 0;
    const char* user = RedisModule_StringPtrLen(username, &userlen);
    const char* pwd = RedisModule_StringPtrLen(password, NULL);
    // Handle the success case by authenticating.
    if (result == 1) {
        RedisModule_AuthenticateClientWithACLUser(ctx, user, userlen, NULL, NULL, NULL);
        return REDISMODULE_AUTH_SUCCEEDED;
    }
    // Handle the Error case by denying auth
    else if (result == 0) {
        *err = "Auth denied by Module.";
        return REDISMODULE_AUTH_DENIED;
    }
    // "Skip" Authentication
    return REDISMODULE_AUTH_NOT_HANDLED;
}

/* Private data freeing callback for custom auths. */
void AuthBlock_FreeData(RedisModuleCtx *ctx, void *privdata) {
    REDISMODULE_NOT_USED(ctx);
    RedisModule_Free(privdata);
}

/* Callback triggered when the engine attempts custom auth
 * Return code here is one of the following: Auth succeeded, Auth denied,
 * Auth not handled, Auth blocked.
 * The Module can have auth succeed / denied here itself, but this is an example
 * of blocking custom auth.
 */
int blocking_auth_cb(RedisModuleCtx *ctx, RedisModuleString *username, RedisModuleString *password, const char **err) {
    REDISMODULE_NOT_USED(username);
    REDISMODULE_NOT_USED(password);
    // Block the client from the Module.
    RedisModuleBlockedClient *bc = RedisModule_BlockClientOnAuth(ctx, AuthBlock_Reply, AuthBlock_FreeData);
    pthread_t tid;
    // Allocate memory for information needed.
    void **targ = RedisModule_Alloc(sizeof(void*)*3);
    targ[0] = bc;
    targ[1] = RedisModule_CreateStringFromString(NULL, username);
    targ[2] = RedisModule_CreateStringFromString(NULL, password);
    // Create bg thread and pass the blockedclient, username and password to it.
    if (pthread_create(&tid, NULL, AuthBlock_ThreadMain, targ) != 0) {
        RedisModule_AbortBlock(bc);
    }
    return REDISMODULE_AUTH_BLOCKED;
}

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    REDISMODULE_NOT_USED(argv);
    REDISMODULE_NOT_USED(argc);
    if (RedisModule_Init(ctx,"misc",1,REDISMODULE_APIVER_1)== REDISMODULE_ERR)
        return REDISMODULE_ERR;
    RedisModule_RegisterAuthCallback(ctx, blocking_auth_cb);
    return REDISMODULE_OK;
}

Based on the Module's usage for the two approaches for custom auth, I thought I’d get feedback on what approach makes sense

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Backlog
Development

No branches or pull requests

4 participants