|
|
@@ -0,0 +1,133 @@
|
|
|
+## Backend Refactoring
|
|
|
+
|
|
|
+The backend refactor has four immediate goals:
|
|
|
+
|
|
|
+- Remove repeated logic from handlers, to make handlers easier to develop and test
|
|
|
+- Create unit tests for handlers
|
|
|
+- Consolidate error handling and logging
|
|
|
+- Type the API request/response/endpoints and export the types as a package
|
|
|
+
|
|
|
+These tasks will eventually enable:
|
|
|
+
|
|
|
+- Application monitoring and error reporting, using the conslidated error handlers and logging
|
|
|
+- Better support with well-defined and predictable logging output
|
|
|
+- Proper CI when developing, which can run unit tests against the backend
|
|
|
+- Auto-generation of the API spec, using the strongly types API requests/responses
|
|
|
+
|
|
|
+## Explanation of Scopes
|
|
|
+
|
|
|
+This refactor abstracts authentication and authorization logic much better than the previous version of the API. The refactor is centered around the concept of _scopes_: each endpoint has a well-defined set of scopes that define which objects that the endpoint can modify or return, thereby enforcing _Role-Based Access Control_. The basic set of scopes operate in the following heirarchy:
|
|
|
+
|
|
|
+```
|
|
|
+ User
|
|
|
+ |
|
|
|
+ Project
|
|
|
+ / \
|
|
|
+ Cluster Settings
|
|
|
+ /
|
|
|
+ Namespace
|
|
|
+ |
|
|
|
+ Release
|
|
|
+```
|
|
|
+
|
|
|
+That is, all project routes are user-scoped (require authenticated user), all cluster scopes require a project, etc.
|
|
|
+
|
|
|
+How do we enforce these scopes? This is the purpose of the `api/server/authz` package, where we make use of Golang's [context](https://go.dev/blog/context) to create middleware that adds resources to the context. For example, the `ProjectScopedMiddleware` object returns a handler that essentially does the following (removed fluff from code):
|
|
|
+
|
|
|
+```go
|
|
|
+// get the referenced project from the database
|
|
|
+project, _ := p.config.Repo.Project().ReadProject(projID)
|
|
|
+
|
|
|
+// add the project to the request context
|
|
|
+ctx := NewProjectContext(r.Context(), project)
|
|
|
+r = r.WithContext(ctx)
|
|
|
+
|
|
|
+// call the next handler
|
|
|
+p.next.ServeHTTP(w, r)
|
|
|
+```
|
|
|
+
|
|
|
+Thus, subsequent endpoint can simply call the following:
|
|
|
+
|
|
|
+```go
|
|
|
+proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
|
|
|
+```
|
|
|
+
|
|
|
+Note that **we assume the resource is populated** -- we do not check for this condition, since this is the function of the middleware.
|
|
|
+
|
|
|
+## Migrating Existing Handlers
|
|
|
+
|
|
|
+The steps for migrating an existing handler are as follows:
|
|
|
+
|
|
|
+1. Create a new file in a sub-folder of `/api/server/handlers`. For example, if creating a cluster-scoped routes, add a new route to `/api/server/handlers/cluster`.
|
|
|
+2. Add some boilerplate to this file as follows:
|
|
|
+
|
|
|
+```go
|
|
|
+type [Resource][Verb]Handler struct {
|
|
|
+ // should extend one of:
|
|
|
+ // handlers.PorterHandler
|
|
|
+ // handlers.PorterHandlerReader
|
|
|
+ // handlers.PorterHandlerWriter
|
|
|
+ // handlers.PorterHandlerReaderWriter
|
|
|
+
|
|
|
+ // TODO: add any additional fields, or embed existing structs/interfaces
|
|
|
+}
|
|
|
+
|
|
|
+func New[Resource][Verb]Handler(
|
|
|
+ config *shared.Config,
|
|
|
+ // TODO: additional arguments
|
|
|
+) *[Resource][Verb]Handler {
|
|
|
+ return &[Resource][Verb]Handler{
|
|
|
+ // TODO: initialize
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func (c *[Resource][Verb]Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
|
+ // TODO: add handler logic here
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+3. Implement the handler logic, you can mostly copy the handler from `/server/api`. Perform the following:
|
|
|
+
|
|
|
+- Replace all unnecessary lookups by finding the object in context (see above for explanation)
|
|
|
+- Replace all error-handling with a method from `apierrors` (look at existing handlers to understand)
|
|
|
+- Use helper methods attached to the `handlers.PorterHandler` interface, which implements the following:
|
|
|
+
|
|
|
+```go
|
|
|
+Config() *shared.Config
|
|
|
+Repo() repository.Repository
|
|
|
+HandleAPIError(w http.ResponseWriter, err apierrors.RequestError)
|
|
|
+```
|
|
|
+
|
|
|
+4. Add the route to the corresponding file in `/api/server/router`. Each route has a corresponding endpoint and handler. For example, if creating a new cluster-scoped route, like `GET /api/projects/{project_id}/clusters/{cluster_id}`, you would add the following to `getClusterRoutes`:
|
|
|
+
|
|
|
+```go
|
|
|
+// GET /api/projects/{project_id}/clusters/{cluster_id} -> project.NewClusterGetHandler
|
|
|
+getEndpoint := factory.NewAPIEndpoint(
|
|
|
+ &types.APIRequestMetadata{
|
|
|
+ Verb: types.APIVerbGet,
|
|
|
+ Method: types.HTTPVerbGet,
|
|
|
+ Path: &types.Path{
|
|
|
+ Parent: basePath,
|
|
|
+ RelativePath: relPath,
|
|
|
+ },
|
|
|
+ Scopes: []types.PermissionScope{
|
|
|
+ // NOTE: important. Make sure all relevant scopes are defined: this definition will
|
|
|
+ // automatically add the relevant middleware.
|
|
|
+ types.UserScope,
|
|
|
+ types.ProjectScope,
|
|
|
+ types.ClusterScope,
|
|
|
+ },
|
|
|
+ },
|
|
|
+)
|
|
|
+
|
|
|
+getHandler := cluster.NewClusterGetHandler(
|
|
|
+ config,
|
|
|
+ factory.GetResultWriter(),
|
|
|
+)
|
|
|
+
|
|
|
+routes = append(routes, &Route{
|
|
|
+ Endpoint: getEndpoint,
|
|
|
+ Handler: getHandler,
|
|
|
+ Router: r,
|
|
|
+})
|
|
|
+```
|