Explorar el Código

Merge branch 'master' of https://github.com/porter-dev/porter into frontend-boilerplate

jusrhee hace 5 años
padre
commit
3137d1da6c

+ 3 - 3
.air.toml

@@ -7,11 +7,11 @@ tmp_dir = "tmp"
 
 [build]
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
 # Binary file yields from `cmd`.
-bin = "tmp/app"
+bin = "tmp/migrate; tmp/app"
 # Customize binary.
-full_bin = "tmp/app"
+full_bin = "tmp/migrate; tmp/app"
 # Watch these filename extensions.
 include_ext = ["go", "mod", "sum", "html"]
 # Ignore these filename extensions or directories.

+ 3 - 0
INT_TEST.md

@@ -9,4 +9,7 @@ curl -d "{\"email\":\"hello\",\"password\":\"hello\"}" -H 'Content-Type: applica
 
 # should pass (without authentication)
 curl -d "{\"email\":\"belanger@getporter.dev\",\"password\":\"hello\"}" -H 'Content-Type: application/json' -X POST localhost:8080/api/users
+
+# should pass
+curl -X DELETE localhost:8080/api/users/1 -d "{\"password\":\"hello\"}"
 ```

BIN
app


+ 9 - 1
cmd/app/main.go

@@ -5,9 +5,12 @@ import (
 	"log"
 	"net/http"
 
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
 	"github.com/porter-dev/porter/server/api"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	vr "github.com/porter-dev/porter/internal/validator"
@@ -25,9 +28,14 @@ func main() {
 		return
 	}
 
+	repo := gorm.NewRepository(db)
+
+	key := []byte("secret") // TODO: change to os.Getenv("SESSION_KEY")
+	store, _ := sessionstore.NewStore(repo, key)
+
 	validator := vr.New()
 
-	a := api.New(logger, db, validator)
+	a := api.New(logger, repo, validator, store)
 
 	appRouter := router.New(a)
 

+ 5 - 1
cmd/migrate/main.go

@@ -22,8 +22,12 @@ func main() {
 		return
 	}
 
-	db.AutoMigrate(
+	err = db.AutoMigrate(
 		&models.User{},
 		&models.ClusterConfig{},
 	)
+
+	if err != nil {
+		panic(err)
+	}
 }

+ 19 - 1
docker-compose.yaml

@@ -29,5 +29,23 @@ services:
     volumes:
       - db:/var/lib/postgresql/data
 
+  metabase:
+    image: metabase/metabase
+    restart: always
+    ports: 
+      - 3000:3000
+    volumes: 
+      - metabase:/metabase-data
+    environment:
+      MB_DB_TYPE: postgres
+      MB_DB_DBNAME: porter
+      MB_DB_PORT: 5432
+      MB_DB_USER: porter
+      MB_DB_PASS: porter
+      MB_DB_HOST: postgres
+    depends_on:
+      - postgres
+
 volumes:
-  db:
+  db:
+  metabase:

+ 369 - 0
docs/API.md

@@ -0,0 +1,369 @@
+**Table of Contents:**
+
+- [Overview](#overview)
+- [Global Errors](#global-errors)
+  - [`ErrorDataWrite`](#errordatawrite)
+  - [`ErrorDataRead`](#errordataread)
+  - [`ErrorInternal`](#errorinternal)
+- [`/api/users`](#apiusers)
+  - [`GET /api/users/{id}`](#get-apiusersid)
+  - [`GET /api/users/{id}/clusters`](#get-apiusersidclusters)
+  - [`GET /api/users/{id}/clusters/all`](#get-apiusersidclustersall)
+  - [`POST /api/users`](#post-apiusers)
+  - [`PUT /api/users/{id}`](#put-apiusersid)
+  - [`DELETE /api/users/{id}`](#delete-apiusersid)
+
+### Overview
+
+This is the API specification that the Go server is implementing. 
+
+**Error handling:**
+
+Errors are passed via both a non-`2xx` status code and an HTTPError response body:
+
+```js
+HTTPError{
+    // The Porter custom error code
+    Code: Number,
+    // A descriptive error message
+    Errors: []String,
+}
+```
+
+Internal server errors are shared across all endpoints and are listed in the [Global Errors](#global-errors) section. 
+
+**Authentication:** The current authentication method is cookie-based sessions--most endpoints require a cookie-based session. 
+
+### Global Errors
+
+#### `ErrorDataWrite`
+
+**Description:** occurs when a write is attempted against the database and fails. 
+
+**Status Code:** `500`
+
+**Response Body:**
+
+```json
+{
+	"Code": 500,
+	"Errors": [{
+		"could not write to database"
+	}],
+}
+```
+
+#### `ErrorDataRead`
+
+**Description:** occurs when a read is attempted against the database and fails. 
+
+**Status Code:** `500`
+
+**Response Body:**
+
+```json
+{
+	"Code": 500,
+	"Errors": [{
+		"could not read from database"
+	}],
+}
+```
+
+#### `ErrorInternal`
+
+**Description:** occurs with a generic internal server error
+
+**Status Code:** `500`
+
+**Response Body:**
+
+```json
+{
+	"Code": 500,
+	"Errors": [{
+		"internal server error"
+	}],
+}
+```
+
+### `/api/users`
+
+#### `GET /api/users/{id}`
+
+**Description:** Gets a user object matching a specific `id`. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: N/A
+
+**Successful Response Body**: 
+
+```js
+User{
+    "id": Number,
+    "email": String,
+    "clusters": []ClusterConfig{
+        "name": String,
+        "server": String,
+        "context": String,
+        "user": String,
+    },
+    "rawKubeConfig": String,
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:**
+
+- User not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+        "code":602,
+        "errors":["could not find requested object"]
+    }
+    ```
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `GET /api/users/{id}/clusters`
+
+**Description:** Retrieves the clusters that are currently linked to a User account. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: N/A
+
+**Successful Response Body**: 
+
+```js
+[]ClusterConfig{
+  "name": String,
+  "server": String,
+  "context": String,
+  "user": String,
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** 
+- User not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+        "code":602,
+        "errors":["could not find requested object"]
+    }
+    ```
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `GET /api/users/{id}/clusters/all`
+
+**Description:** Parses all clusters from the user's kubeconfig and returns a list of viable cluster configs. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: N/A
+
+**Successful Response Body**: 
+
+```js
+[]ClusterConfig{
+  "name": String,
+  "server": String,
+  "context": String,
+  "user": String,
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** 
+- User not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+        "code":602,
+        "errors":["could not find requested object"]
+    }
+    ```
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `POST /api/users`
+
+**Description:** Creates a new user with a given email and password.
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: 
+
+```js
+{
+    "email": String,
+    "password": String,
+}
+```
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `201`
+
+**Errors:**
+
+- Invalid email (example: `{"email": "notanemail"}`)
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["email validation failed"]
+    }
+    ```
+
+- Missing field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["required validation failed"]
+    }`
+    ```
+
+- Email already taken 
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["email already taken"]
+    }
+    ```
+
+#### `PUT /api/users/{id}`
+
+**Description:** Updates an existing user.
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request body:**
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `204`
+
+**Errors:** 
+
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `DELETE /api/users/{id}`
+
+**Description:** Deletes an existing user, requires the password to be sent before deletion. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request body:**
+
+```js
+{
+    "password": String,
+}
+```
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `204`
+
+**Errors:** 
+
+- Invalid `password`
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["invalid password"]
+    }
+    ```
+    
+- Missing field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["required validation failed"]
+    }`
+    ```
+
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+

+ 9 - 3
go.mod

@@ -3,6 +3,7 @@ module github.com/porter-dev/porter
 go 1.14
 
 require (
+	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/creack/pty v1.1.11 // indirect
 	github.com/fatih/color v1.9.0 // indirect
@@ -10,22 +11,27 @@ require (
 	github.com/go-playground/locales v0.13.0
 	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
+	github.com/go-test/deep v1.0.7
+	github.com/gorilla/securecookie v1.1.1
+	github.com/gorilla/sessions v1.2.1
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/jinzhu/gorm v1.9.16
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/mattn/go-colorable v0.1.7 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
+	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
-	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a // indirect
+	github.com/stretchr/testify v1.5.1
+	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
 	golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
 	golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/yaml.v2 v2.3.0
-	gorm.io/driver/postgres v1.0.1
-	gorm.io/gorm v1.20.1
+	gorm.io/driver/postgres v1.0.2
+	gorm.io/gorm v1.20.2
 	k8s.io/apimachinery v0.19.2
 	k8s.io/client-go v0.0.0-20200917000235-cba7285b7f29
 	k8s.io/klog v1.0.0 // indirect

+ 27 - 0
go.sum

@@ -41,6 +41,8 @@ github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
+github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
 github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
@@ -108,6 +110,8 @@ github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+
 github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
 github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
+github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
 github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
 github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls=
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
@@ -164,6 +168,10 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gnostic v0.4.1 h1:DLJCy1n/vrD4HPjOvYcT8aYQXpPIzoRZONaYwyycI+I=
 github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -186,6 +194,8 @@ github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr
 github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
 github.com/jackc/pgconn v1.6.4 h1:S7T6cx5o2OqmxdHaXLH1ZeD1SbI8jBznyYE9Ec0RCQ8=
 github.com/jackc/pgconn v1.6.4/go.mod h1:w2pne1C2tZgP+TvjqLpOigGzNqjBgQW9dUw/4Chex78=
+github.com/jackc/pgconn v1.7.0 h1:pwjzcYyfmz/HQOQlENvG1OcDqauTGaqlVahq934F0/U=
+github.com/jackc/pgconn v1.7.0/go.mod h1:sF/lPpNEMEOp+IYhyQGdAvrG20gWf6A1tKlr0v7JMeA=
 github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
 github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
 github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
@@ -200,6 +210,8 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:
 github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
 github.com/jackc/pgproto3/v2 v2.0.2 h1:q1Hsy66zh4vuNsajBUF2PNqfAMMfxU5mk594lPE9vjY=
 github.com/jackc/pgproto3/v2 v2.0.2/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
+github.com/jackc/pgproto3/v2 v2.0.5 h1:NUbEWPmCQZbMmYlTjVoNPhc0CfnYyz2bfUAh6A5ZVJM=
+github.com/jackc/pgproto3/v2 v2.0.5/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
 github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
@@ -211,6 +223,8 @@ github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkAL
 github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
 github.com/jackc/pgtype v1.4.2 h1:t+6LWm5eWPLX1H5Se702JSBcirq6uWa4jiG4wV1rAWY=
 github.com/jackc/pgtype v1.4.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
+github.com/jackc/pgtype v1.5.0 h1:jzBqRk2HFG2CV4AIwgCI2PwTgm6UUoCAK2ofHHRirtc=
+github.com/jackc/pgtype v1.5.0/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
 github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
 github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
 github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
@@ -219,10 +233,13 @@ github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6
 github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
 github.com/jackc/pgx/v4 v4.8.1 h1:SUbCLP2pXvf/Sr/25KsuI4aTxiFYIvpfk4l6aTSdyCw=
 github.com/jackc/pgx/v4 v4.8.1/go.mod h1:4HOLxrl8wToZJReD04/yB20GDwf4KBYETvlHciCnwW0=
+github.com/jackc/pgx/v4 v4.9.0 h1:6STjDqppM2ROy5p1wNDcsC7zJTjSHeuCsguZmXyzx7c=
+github.com/jackc/pgx/v4 v4.9.0/go.mod h1:MNGWmViCgqbZck9ujOOBN63gK9XVGILXWCvKLGKmnms=
 github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
+github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
 github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
 github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -289,7 +306,9 @@ github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNC
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
 github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
@@ -309,10 +328,12 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
 github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -619,8 +640,14 @@ gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gorm.io/driver/postgres v1.0.1 h1:jRfDNUxpxNrea/97kbcscAQGmiks4UCKAYXsvh4rhOQ=
 gorm.io/driver/postgres v1.0.1/go.mod h1:pv4dVhHvEVrP7k/UYqdBIllbdbpB5VTz89X1O0uOrCA=
+gorm.io/driver/postgres v1.0.2 h1:mB5JjD4QglbCTdMT1aZDxQzHr87XDK1qh0MKIU3P96g=
+gorm.io/driver/postgres v1.0.2/go.mod h1:FvRSYfBI9jEp6ZSjlpS9qNcSjxwYxFc03UOTrHdvvYA=
+gorm.io/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
+gorm.io/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
 gorm.io/gorm v1.20.1 h1:+hOwlHDqvqmBIMflemMVPLJH7tZYK4RxFDBHEfJTup0=
 gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
+gorm.io/gorm v1.20.2 h1:bZzSEnq7NDGsrd+n3evOOedDrY5oLM5QPlCjZJUK2ro=
+gorm.io/gorm v1.20.2/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

+ 0 - 1
internal/adapter/gorm.go

@@ -9,7 +9,6 @@ import (
 )
 
 // New returns a new gorm database instance
-// TODO -- accept config to generate connection
 func New(conf *config.DBConf) (*gorm.DB, error) {
 	dsn := fmt.Sprintf(
 		"user=%s password=%s port=%d host=%s sslmode=disable",

+ 63 - 0
internal/auth/example/authExample.go

@@ -0,0 +1,63 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	dbConn "github.com/porter-dev/porter/internal/adapter"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
+)
+
+var appConf = config.AppConfig()
+
+var db, dbErr = dbConn.New(&appConf.Db)
+
+var (
+	key      = []byte("secret") // change to os.Getenv("SESSION_KEY")
+	store, _ = sessionstore.NewStore(gorm.NewRepository(db), key)
+)
+
+func secret(w http.ResponseWriter, r *http.Request) {
+
+	session, _ := store.Get(r, "cookie-name")
+	fmt.Println(session.Values["authenticated"])
+
+	// Check if user is authenticated
+	if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
+		http.Error(w, "Forbidden", http.StatusForbidden)
+		return
+	}
+
+	// Print secret message
+	fmt.Fprintln(w, "The cake is a lie!")
+}
+
+func login(w http.ResponseWriter, r *http.Request) {
+	session, _ := store.Get(r, "cookie-name")
+
+	// Authentication goes here
+	// ...
+
+	// Set user as authenticated
+	session.Values["authenticated"] = true
+	session.Save(r, w)
+}
+
+func logout(w http.ResponseWriter, r *http.Request) {
+	session, _ := store.Get(r, "cookie-name")
+
+	// Revoke users authentication
+	session.Values["authenticated"] = false
+	session.Save(r, w)
+}
+
+func main() {
+	http.HandleFunc("/secret", secret)
+	http.HandleFunc("/login", login)
+	http.HandleFunc("/logout", logout)
+
+	http.ListenAndServe(":8080", nil)
+}

+ 192 - 0
internal/auth/sessionstore.go

@@ -0,0 +1,192 @@
+// Package sessionstore is a postgresql backend implementation of gorilla/sessions Session interface, based on
+// antonlindstrom/pgstore. Key change is to use GORM instead of typical sql driver using queries.
+package sessionstore
+
+import (
+	"database/sql"
+	"encoding/base32"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/gorilla/securecookie"
+	"github.com/gorilla/sessions"
+	"github.com/pkg/errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// structs
+
+// PGStore is a wrapper around gorilla/sessions store.
+type PGStore struct {
+	Codecs  []securecookie.Codec
+	Options *sessions.Options
+	Path    string
+	Repo    *repository.Repository
+}
+
+// Helpers
+
+// MaxLength restricts the maximum length of new sessions to l.
+// If l is 0 there is no limit to the size of a session, use with caution.
+// The default for a new PGStore is 4096. PostgreSQL allows for max
+// value sizes of up to 1GB (http://www.postgresql.org/docs/current/interactive/datatype-character.html)
+func (store *PGStore) MaxLength(l int) {
+	for _, c := range store.Codecs {
+		if codec, ok := c.(*securecookie.SecureCookie); ok {
+			codec.MaxLength(l)
+		}
+	}
+}
+
+// MaxAge sets the maximum age for the store and the underlying cookie
+// implementation. Individual sessions can be deleted by setting Options.MaxAge
+// = -1 for that session.
+func (store *PGStore) MaxAge(age int) {
+	store.Options.MaxAge = age
+
+	// Set the maxAge for each securecookie instance.
+	for _, codec := range store.Codecs {
+		if sc, ok := codec.(*securecookie.SecureCookie); ok {
+			sc.MaxAge(age)
+		}
+	}
+}
+
+// load fetches a session by ID from the database and decodes its content
+// into session.Values.
+func (store *PGStore) load(session *sessions.Session) error {
+	res, err := store.Repo.Session.SelectSession(&models.Session{Key: session.ID})
+
+	if err != nil {
+		return err
+	}
+
+	return securecookie.DecodeMulti(session.Name(), string(res.Data), &session.Values, store.Codecs...)
+}
+
+// save writes encoded session.Values to a database record.
+// writes to http_sessions table by default.
+func (store *PGStore) save(session *sessions.Session) error {
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, store.Codecs...)
+	if err != nil {
+		return err
+	}
+
+	exOn := session.Values["expires_on"]
+
+	var expiresOn time.Time
+
+	if exOn == nil {
+		expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
+	} else {
+		expiresOn = exOn.(time.Time)
+		if expiresOn.Sub(time.Now().Add(time.Second*time.Duration(session.Options.MaxAge))) < 0 {
+			expiresOn = time.Now().Add(time.Second * time.Duration(session.Options.MaxAge))
+		}
+	}
+
+	s := &models.Session{
+		Key:       session.ID,
+		Data:      []byte(encoded),
+		ExpiresAt: expiresOn,
+	}
+
+	repo := store.Repo
+
+	if session.IsNew {
+		_, createErr := repo.Session.CreateSession(s)
+		return createErr
+	}
+
+	_, updateErr := repo.Session.UpdateSession(s)
+	return updateErr
+}
+
+// Implementation of the interface (Get, New, Save)
+
+// NewStore takes an initialized db and session key pairs to create a session-store in postgres db.
+func NewStore(repo *repository.Repository, keyPairs ...[]byte) (*PGStore, error) {
+	dbStore := &PGStore{
+		Codecs: securecookie.CodecsFromPairs(keyPairs...),
+		Options: &sessions.Options{
+			Path:   "/",
+			MaxAge: 86400 * 30,
+		},
+		Repo: repo,
+	}
+
+	return dbStore, nil
+}
+
+// Get Fetches a session for a given name after it has been added to the
+// registry.
+func (store *PGStore) Get(r *http.Request, name string) (*sessions.Session, error) {
+	return sessions.GetRegistry(r).Get(store, name)
+}
+
+// New returns a new session for the given name without adding it to the registry.
+func (store *PGStore) New(r *http.Request, name string) (*sessions.Session, error) {
+	session := sessions.NewSession(store, name)
+	if session == nil {
+		return nil, nil
+	}
+
+	opts := *store.Options
+	session.Options = &(opts)
+	session.IsNew = true
+
+	var err error
+	if c, errCookie := r.Cookie(name); errCookie == nil {
+		err = securecookie.DecodeMulti(name, c.Value, &session.ID, store.Codecs...)
+		if err == nil {
+			err = store.load(session)
+			if err == nil {
+				session.IsNew = false
+			} else if errors.Cause(err) == sql.ErrNoRows {
+				err = nil
+			}
+		}
+	}
+
+	store.MaxAge(store.Options.MaxAge)
+
+	return session, err
+}
+
+// Save saves the given session into the database and deletes cookies if needed
+func (store *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+	repo := store.Repo
+
+	// Set delete if max-age is < 0
+	if session.Options.MaxAge < 0 {
+		if _, err := repo.Session.DeleteSession(&models.Session{Key: session.ID}); err != nil {
+			return err
+		}
+		http.SetCookie(w, sessions.NewCookie(session.Name(), "", session.Options))
+		return nil
+	}
+
+	if session.ID == "" {
+		// Generate a random session ID key suitable for storage in the DB
+		session.ID = strings.TrimRight(
+			base32.StdEncoding.EncodeToString(
+				securecookie.GenerateRandomKey(32),
+			), "=")
+	}
+
+	if err := store.save(session); err != nil {
+		return err
+	}
+
+	// Keep the session ID key in a cookie so it can be looked up in DB later.
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, store.Codecs...)
+	if err != nil {
+		return err
+	}
+
+	http.SetCookie(w, sessions.NewCookie(session.Name(), encoded, session.Options))
+	return nil
+}

+ 151 - 0
internal/auth/sessionstore_test.go

@@ -0,0 +1,151 @@
+package sessionstore
+
+import (
+	"encoding/base64"
+	"net/http"
+	"testing"
+
+	"github.com/gorilla/securecookie"
+	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/repository/test"
+)
+
+type headerOnlyResponseWriter http.Header
+
+func (ho headerOnlyResponseWriter) Header() http.Header {
+	return http.Header(ho)
+}
+
+func (ho headerOnlyResponseWriter) Write([]byte) (int, error) {
+	panic("NOIMPL")
+}
+
+func (ho headerOnlyResponseWriter) WriteHeader(int) {
+	panic("NOIMPL")
+}
+
+var secret = "secret"
+
+func TestPGStore(t *testing.T) {
+	repo := test.NewRepository(true)
+
+	ss, err := NewStore(repo, []byte(secret))
+	if err != nil {
+		t.Fatal("Failed to get store", err)
+	}
+
+	// ROUND 1 - Check that the cookie is being saved
+	req, err := http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create request", err)
+	}
+
+	session, err := ss.Get(req, "mysess")
+	if err != nil {
+		t.Fatal("failed to get session", err.Error())
+	}
+
+	session.Values["counter"] = 1
+
+	m := make(http.Header)
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err != nil {
+		t.Fatal("Failed to save session:", err.Error())
+	}
+
+	if m["Set-Cookie"][0][0:6] != "mysess" {
+		t.Fatal("Cookie wasn't set!")
+	}
+
+	// ROUND 2 - check that the cookie can be retrieved
+	req, err = http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create round 2 request", err)
+	}
+
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, ss.Codecs...)
+	if err != nil {
+		t.Fatal("Failed to make cookie value", err)
+	}
+
+	req.AddCookie(sessions.NewCookie(session.Name(), encoded, session.Options))
+
+	session, err = ss.Get(req, "mysess")
+	if err != nil {
+		t.Fatal("failed to get round 2 session", err.Error())
+	}
+
+	if session.Values["counter"] != 1 {
+		t.Fatal("Retrieved session had wrong value:", session.Values["counter"])
+	}
+
+	session.Values["counter"] = 9 // set new value for round 3
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err != nil {
+		t.Fatal("Failed to save session:", err.Error())
+	}
+
+	// ROUND 2 - check that the cookie has been updated
+	req, err = http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create round 3 request", err)
+	}
+	req.AddCookie(sessions.NewCookie(session.Name(), encoded, session.Options))
+
+	session, err = ss.Get(req, "mysess")
+	if err != nil {
+		t.Fatal("failed to get session round 3", err.Error())
+	}
+
+	if session.Values["counter"] != 9 {
+		t.Fatal("Retrieved session had wrong value in round 3:", session.Values["counter"])
+	}
+
+	// ROUND 3 - Increase max length
+	req, err = http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("failed to create round 3 request", err)
+	}
+
+	req.AddCookie(sessions.NewCookie(session.Name(), encoded, session.Options))
+	session, err = ss.New(req, "my session")
+	if err != nil {
+		t.Fatal("failed to create session", err)
+	}
+
+	session.Values["big"] = make([]byte, base64.StdEncoding.DecodedLen(4096*2))
+
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err == nil {
+		t.Fatal("expected an error, got nil")
+	}
+
+	ss.MaxLength(4096 * 3) // A bit more than the value size to account for encoding overhead.
+	if err = ss.Save(req, headerOnlyResponseWriter(m), session); err != nil {
+		t.Fatal("Failed to save session:", err.Error())
+	}
+}
+
+func TestSessionOptionsAreUniquePerSession(t *testing.T) {
+	repo := test.NewRepository(true)
+
+	ss, err := NewStore(repo, []byte(secret))
+	if err != nil {
+		t.Fatal("Failed to get store", err)
+	}
+
+	ss.Options.MaxAge = 900
+
+	req, err := http.NewRequest("GET", "http://www.example.com", nil)
+	if err != nil {
+		t.Fatal("Failed to create request", err)
+	}
+
+	session, err := ss.Get(req, "newsess")
+	if err != nil {
+		t.Fatal("Failed to create session", err)
+	}
+
+	session.Options.MaxAge = -1
+
+	if ss.Options.MaxAge != 900 {
+		t.Fatalf("PGStore.Options.MaxAge: expected %d, got %d", 900, ss.Options.MaxAge)
+	}
+}

+ 104 - 0
internal/forms/user.go

@@ -0,0 +1,104 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+// WriteUserForm is a generic form for write operations to the User model
+type WriteUserForm interface {
+	ToUser() (*models.User, error)
+}
+
+// CreateUserForm represents the accepted values for creating a user
+type CreateUserForm struct {
+	WriteUserForm
+	Email    string `json:"email" form:"required,max=255,email"`
+	Password string `json:"password" form:"required,max=255"`
+}
+
+// ToUser converts a CreateUserForm to models.User
+func (cuf *CreateUserForm) ToUser() (*models.User, error) {
+	hashed, err := bcrypt.GenerateFromPassword([]byte(cuf.Password), 8)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.User{
+		Email:    cuf.Email,
+		Password: string(hashed),
+	}, nil
+}
+
+// LoginUserForm represents the accepted values for logging a user in
+type LoginUserForm struct {
+	WriteUserForm
+	ID       uint   `form:"required"`
+	Email    string `json:"email" form:"required,max=255,email"`
+	Password string `json:"password" form:"required,max=255"`
+}
+
+// ToUser converts a LoginUserForm to models.User
+func (luf *LoginUserForm) ToUser() (*models.User, error) {
+	hashed, err := bcrypt.GenerateFromPassword([]byte(luf.Password), 8)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.User{
+		Model: gorm.Model{
+			ID: luf.ID,
+		},
+		Email:    luf.Email,
+		Password: string(hashed),
+	}, nil
+}
+
+// UpdateUserForm represents the accepted values for updating a user
+//
+// ID is a query parameter, the other two are sent in JSON body
+type UpdateUserForm struct {
+	WriteUserForm
+	ID              uint     `form:"required"`
+	RawKubeConfig   string   `json:"rawKubeConfig" form:"required"`
+	AllowedClusters []string `json:"allowedClusters" form:"required"`
+}
+
+// ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig
+// and the allowed clusters to generate a list of ClusterConfigs.
+func (uuf *UpdateUserForm) ToUser() (*models.User, error) {
+	rawBytes := []byte(uuf.RawKubeConfig)
+	clusters, err := kubernetes.GetAllowedClusterConfigsFromBytes(rawBytes, uuf.AllowedClusters)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.User{
+		Model: gorm.Model{
+			ID: uuf.ID,
+		},
+		Clusters:      clusters,
+		RawKubeConfig: rawBytes,
+	}, nil
+}
+
+// DeleteUserForm represents the accepted values for deleting a user
+type DeleteUserForm struct {
+	WriteUserForm
+	ID       uint   `form:"required"`
+	Password string `json:"password" form:"required,max=255"`
+}
+
+// ToUser converts a DeleteUserForm to models.User using the user ID
+func (uuf *DeleteUserForm) ToUser() (*models.User, error) {
+	return &models.User{
+		Model: gorm.Model{
+			ID: uuf.ID,
+		},
+	}, nil
+}

+ 87 - 5
internal/kubernetes/kubeconfig.go

@@ -1,6 +1,9 @@
 package kubernetes
 
-import "github.com/porter-dev/porter/internal/models"
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"gopkg.in/yaml.v2"
+)
 
 // KubeConfigCluster represents the cluster field in a kubeconfig
 type KubeConfigCluster struct {
@@ -32,10 +35,78 @@ type KubeConfig struct {
 	Users          []KubeConfigUser    `yaml:"users"`
 }
 
-// ToClusterConfigs converts a KubeConfig to a set of ClusterConfigExternals by
+// GetAllowedClusterConfigsFromBytes converts a raw string to a set of ClusterConfigs
+// by unmarshaling and calling (*KubeConfig).ToAllowedClusterConfigs
+func GetAllowedClusterConfigsFromBytes(bytes []byte, allowedClusters []string) ([]models.ClusterConfig, error) {
+	conf := KubeConfig{}
+	err := yaml.Unmarshal(bytes, &conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clusters := conf.ToAllowedClusterConfigs(allowedClusters)
+
+	return clusters, nil
+}
+
+// GetAllClusterConfigsFromBytes converts a raw string to a set of ClusterConfigs
+// by unmarshaling and calling (*KubeConfig).ToAllClusterConfigs
+func GetAllClusterConfigsFromBytes(bytes []byte) ([]models.ClusterConfig, error) {
+	conf := KubeConfig{}
+	err := yaml.Unmarshal(bytes, &conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clusters := conf.ToAllClusterConfigs()
+
+	return clusters, nil
+}
+
+// ToAllowedClusterConfigs converts a KubeConfig to a set of ClusterConfigs by
+// joining users and clusters on the context.
+//
+// It accepts a list of cluster names that the user wishes to connect to
+func (k *KubeConfig) ToAllowedClusterConfigs(allowedClusters []string) []models.ClusterConfig {
+	clusters := make([]models.ClusterConfig, 0)
+
+	// convert clusters, contexts, and users to maps for fast lookup
+	clusterMap := k.createClusterMap()
+	contextMap := k.createContextMap()
+	userMap := k.createUserMap()
+
+	// put allowed clusters in map
+	aClusterMap := createAllowedClusterMap(allowedClusters)
+
+	// iterate through context maps and link to a user-cluster pair
+	for contextName, context := range contextMap {
+		userName := context.Context.User
+		clusterName := context.Context.Cluster
+		_, userFound := userMap[userName]
+		cluster, clusterFound := clusterMap[clusterName]
+
+		// make sure the cluster is "allowed"
+		_, aClusterFound := aClusterMap[clusterName]
+
+		if userFound && clusterFound && aClusterFound {
+			clusters = append(clusters, models.ClusterConfig{
+				Name:    clusterName,
+				Server:  cluster.Cluster.Server,
+				Context: contextName,
+				User:    userName,
+			})
+		}
+	}
+
+	return clusters
+}
+
+// ToAllClusterConfigs converts a KubeConfig to a set of ClusterConfigs by
 // joining users and clusters on the context.
-func (k *KubeConfig) ToClusterConfigs() []*models.ClusterConfigExternal {
-	clusters := make([]*models.ClusterConfigExternal, 0)
+func (k *KubeConfig) ToAllClusterConfigs() []models.ClusterConfig {
+	clusters := make([]models.ClusterConfig, 0)
 
 	// convert clusters, contexts, and users to maps for fast lookup
 	clusterMap := k.createClusterMap()
@@ -50,7 +121,7 @@ func (k *KubeConfig) ToClusterConfigs() []*models.ClusterConfigExternal {
 		cluster, clusterFound := clusterMap[clusterName]
 
 		if userFound && clusterFound {
-			clusters = append(clusters, &models.ClusterConfigExternal{
+			clusters = append(clusters, models.ClusterConfig{
 				Name:    clusterName,
 				Server:  cluster.Cluster.Server,
 				Context: contextName,
@@ -62,6 +133,17 @@ func (k *KubeConfig) ToClusterConfigs() []*models.ClusterConfigExternal {
 	return clusters
 }
 
+// createAllowedClusterMap creates a map from a cluster name to a KubeConfigCluster object
+func createAllowedClusterMap(clusters []string) map[string]string {
+	aClusterMap := make(map[string]string)
+
+	for _, cluster := range clusters {
+		aClusterMap[cluster] = cluster
+	}
+
+	return aClusterMap
+}
+
 // createClusterMap creates a map from a cluster name to a KubeConfigCluster object
 func (k *KubeConfig) createClusterMap() map[string]KubeConfigCluster {
 	clusterMap := make(map[string]KubeConfigCluster)

+ 243 - 0
internal/kubernetes/kubeconfig_test.go

@@ -0,0 +1,243 @@
+package kubernetes_test
+
+import (
+	"reflect"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"gopkg.in/yaml.v2"
+)
+
+type KubeConfigTest struct {
+	msg             string
+	raw             []byte
+	allowedClusters []string
+	expected        []models.ClusterConfig
+}
+
+var MissingFieldsTest = []KubeConfigTest{
+	KubeConfigTest{
+		msg:             "no fields at all",
+		raw:             []byte(""),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no contexts to join",
+		raw:             []byte(noContexts),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no clusters to join",
+		raw:             []byte(noClusters),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no users to join",
+		raw:             []byte(noUsers),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no cluster contexts to join",
+		raw:             []byte(noContextClusters),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+	KubeConfigTest{
+		msg:             "no cluster users to join",
+		raw:             []byte(noContextUsers),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+}
+
+func TestToClusterConfigsMissingFields(t *testing.T) {
+	for _, c := range MissingFieldsTest {
+		// take raw and decode
+		conf := kubernetes.KubeConfig{}
+		err := yaml.Unmarshal(c.raw, &conf)
+
+		if err != nil {
+			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
+		}
+
+		res := conf.ToAllowedClusterConfigs(c.allowedClusters)
+
+		isEqual := reflect.DeepEqual(c.expected, res)
+
+		if !isEqual {
+			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		}
+	}
+}
+
+var NoAllowedClustersTests = []KubeConfigTest{
+	KubeConfigTest{
+		msg:             "basic test",
+		raw:             []byte(basic),
+		allowedClusters: []string{},
+		expected:        []models.ClusterConfig{},
+	},
+}
+
+func TestToClusterConfigsNoAllowedClusters(t *testing.T) {
+	for _, c := range NoAllowedClustersTests {
+		// take raw and decode
+
+		conf := kubernetes.KubeConfig{}
+		err := yaml.Unmarshal(c.raw, &conf)
+
+		if err != nil {
+			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
+		}
+
+		res := conf.ToAllowedClusterConfigs(c.allowedClusters)
+
+		isEqual := reflect.DeepEqual(c.expected, res)
+
+		if !isEqual {
+			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		}
+	}
+}
+
+var BasicClustersTests = []KubeConfigTest{
+	KubeConfigTest{
+		msg:             "basic test",
+		raw:             []byte(basic),
+		allowedClusters: []string{"cluster-test"},
+		expected: []models.ClusterConfig{
+			models.ClusterConfig{
+				Name:    "cluster-test",
+				Server:  "https://localhost",
+				Context: "context-test",
+				User:    "test-admin",
+			},
+		},
+	},
+}
+
+func TestToClusterConfigsBasic(t *testing.T) {
+	for _, c := range BasicClustersTests {
+		// take raw and decode
+		conf := kubernetes.KubeConfig{}
+		err := yaml.Unmarshal(c.raw, &conf)
+
+		if err != nil {
+			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
+		}
+
+		res := conf.ToAllowedClusterConfigs(c.allowedClusters)
+
+		isEqual := reflect.DeepEqual(c.expected, res)
+
+		if !isEqual {
+			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
+		}
+	}
+}
+
+const noContexts string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+current-context: default
+users:
+- name: test-admin
+  user:
+`
+
+const noClusters string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+contexts:
+- context:
+    cluster: porter-test-1
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+`
+
+const noUsers string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+contexts:
+- context:
+    cluster: porter-test-1
+    user: test-admin
+  name: context-test
+`
+
+const noContextClusters string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+contexts:
+- context:
+    # cluster: porter-test-1
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+`
+
+const noContextUsers string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: porter-test-1
+contexts:
+- context:
+    cluster: porter-test-1
+    # user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+`
+
+const basic string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: default
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+  - name: test-admin
+`

+ 1 - 1
internal/models/cluster_configs.go

@@ -1,6 +1,6 @@
 package models
 
-import "github.com/jinzhu/gorm"
+import "gorm.io/gorm"
 
 // ClusterConfig that extends gorm.Model
 //

+ 38 - 0
internal/models/cluster_configs_test.go

@@ -0,0 +1,38 @@
+package models_test
+
+import (
+	"testing"
+
+	"gorm.io/gorm"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestClusterConfigExternalize(t *testing.T) {
+	cc := &models.ClusterConfig{
+		Model: gorm.Model{
+			ID: 1,
+		},
+		Name:   "test",
+		Server: "localhost",
+		User:   "test",
+		UserID: 1,
+	}
+
+	extCC := *cc.Externalize()
+
+	if extCC.Name != cc.Name {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Name", extCC.Name, cc.Name)
+	}
+
+	if extCC.Server != cc.Server {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Server", extCC.Server, cc.Server)
+	}
+
+	if extCC.User != cc.User {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "User", extCC.User, cc.User)
+	}
+
+	if extCC.Context != cc.Context {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Context", extCC.Context, cc.Context)
+	}
+}

+ 18 - 0
internal/models/session.go

@@ -0,0 +1,18 @@
+package models
+
+import (
+	"time"
+
+	"github.com/jinzhu/gorm"
+)
+
+// Session type that extends gorm.Model.
+type Session struct {
+	gorm.Model
+	// Session ID
+	Key string `gorm:"unique"`
+	// encrypted cookie
+	Data []byte
+	// Time the session will expire
+	ExpiresAt time.Time
+}

+ 6 - 30
internal/models/user.go

@@ -1,20 +1,17 @@
 package models
 
 import (
-	"github.com/jinzhu/gorm"
+	"gorm.io/gorm"
 )
 
 // User type that extends gorm.Model
 type User struct {
 	gorm.Model
-	// Unique email for each user
-	Email,
-	// Hashed password
-	Password string
-	// The clusters that this user has linked
-	Clusters []ClusterConfig
-	// The raw kubeconfig uploaded by this user
-	RawKubeConfig []byte
+
+	Email         string          `json:"email" gorm:"unique"`
+	Password      string          `json:"password"`
+	Clusters      []ClusterConfig `json:"clusters"`
+	RawKubeConfig []byte          `json:"rawKubeConfig"`
 }
 
 // UserExternal represents the User type that is sent over REST
@@ -25,17 +22,6 @@ type UserExternal struct {
 	RawKubeConfig string                   `json:"rawKubeConfig"`
 }
 
-// CreateUserForm represents the accepted values for creating a user
-type CreateUserForm struct {
-	Email    string `json:"email" form:"required,max=255,email"`
-	Password string `json:"password" form:"required,max=255"`
-}
-
-// UpdateUserForm represents the accepted values for updating a user
-type UpdateUserForm struct {
-	RawKubeConfig string `json:"rawKubeConfig"`
-}
-
 // Externalize generates an external User to be shared over REST
 func (u *User) Externalize() *UserExternal {
 	clustersExt := make([]*ClusterConfigExternal, 0)
@@ -51,13 +37,3 @@ func (u *User) Externalize() *UserExternal {
 		RawKubeConfig: string(u.RawKubeConfig),
 	}
 }
-
-// ToUser converts a user form to a user
-//
-// TODO -- PASSWORD HASHING HERE
-func (cuf *CreateUserForm) ToUser() (*User, error) {
-	return &User{
-		Email:    cuf.Email,
-		Password: cuf.Password,
-	}, nil
-}

+ 46 - 0
internal/models/user_test.go

@@ -0,0 +1,46 @@
+package models_test
+
+import (
+	"testing"
+
+	"gorm.io/gorm"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestUserExternalize(t *testing.T) {
+	// create a new user
+	user := &models.User{
+		Model: gorm.Model{
+			ID: 1,
+		},
+		Email:    "testing@testing.com",
+		Password: "testing123",
+		Clusters: []models.ClusterConfig{
+			models.ClusterConfig{
+				Name:   "test",
+				Server: "localhost",
+				User:   "test",
+				UserID: 1,
+			},
+		},
+		RawKubeConfig: []byte{},
+	}
+
+	extUser := *user.Externalize()
+
+	if extUser.ID != user.ID {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "ID", user.ID, extUser.ID)
+	}
+
+	if extUser.Email != user.Email {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Email", user.Email, extUser.Email)
+	}
+
+	if len(extUser.Clusters) != 1 {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length Clusters", len(extUser.Clusters), 1)
+	}
+
+	if len(extUser.RawKubeConfig) != 0 {
+		t.Errorf("Field: %s\t Int: %v\t Ext: %v\n", "Length RawKubeConfig", len(extUser.RawKubeConfig), 0)
+	}
+}

+ 0 - 23
internal/queries/user.go

@@ -1,23 +0,0 @@
-package queries
-
-import (
-	"github.com/porter-dev/porter/internal/models"
-	"gorm.io/gorm"
-)
-
-// CreateUser adds a new User row to the Users table in the database
-func CreateUser(db *gorm.DB, user *models.User) (*models.User, error) {
-	if err := db.Create(user).Error; err != nil {
-		return nil, err
-	}
-	return user, nil
-}
-
-// UpdateUser modifies an existing User in the database
-func UpdateUser(db *gorm.DB, user *models.User) error {
-	if err := db.First(&models.User{}, user.ID).Updates(user).Error; err != nil {
-		return err
-	}
-
-	return nil
-}

+ 15 - 0
internal/repository/gorm/repository.go

@@ -0,0 +1,15 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// NewRepository returns a Repository which uses
+// gorm.DB for querying the database
+func NewRepository(db *gorm.DB) *repository.Repository {
+	return &repository.Repository{
+		User:    NewUserRepository(db),
+		Session: NewSessionRepository(db),
+	}
+}

+ 53 - 0
internal/repository/gorm/session.go

@@ -0,0 +1,53 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// SessionRepository uses gorm.DB for querying the database
+type SessionRepository struct {
+	db *gorm.DB
+}
+
+// NewSessionRepository returns pointer to repo along with the db
+func NewSessionRepository(db *gorm.DB) repository.SessionRepository {
+	return &SessionRepository{db}
+}
+
+// CreateSession must take in Key, Data, and ExpiresAt as arguments.
+func (s *SessionRepository) CreateSession(session *models.Session) (*models.Session, error) {
+	if err := s.db.Create(session).Error; err != nil {
+		return nil, err
+	}
+	return session, nil
+}
+
+// UpdateSession updates only the Data field using Key as selector.
+func (s *SessionRepository) UpdateSession(session *models.Session) (*models.Session, error) {
+	if err := s.db.Model(session).Where("Key = ?", session.Key).Updates(session).Error; err != nil {
+		return nil, err
+	}
+	return session, nil
+}
+
+// DeleteSession deletes a session by Key
+func (s *SessionRepository) DeleteSession(session *models.Session) (*models.Session, error) {
+
+	if err := s.db.Where("Key = ?", session.Key).Delete(session).Error; err != nil {
+		return nil, err
+	}
+
+	return session, nil
+}
+
+// SelectSession returns a session with matching key
+func (s *SessionRepository) SelectSession(session *models.Session) (*models.Session, error) {
+
+	if err := s.db.Where("Key = ?", session.Key).First(session).Error; err != nil {
+		return nil, err
+	}
+
+	return session, nil
+}

+ 149 - 0
internal/repository/gorm/session_test.go

@@ -0,0 +1,149 @@
+package gorm
+
+import (
+	"database/sql"
+	"testing"
+	"time"
+
+	"gorm.io/driver/postgres"
+
+	"github.com/DATA-DOG/go-sqlmock"
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
+	"gorm.io/gorm"
+)
+
+type Suite struct {
+	suite.Suite
+	db   *gorm.DB
+	mock sqlmock.Sqlmock
+
+	repo    repository.SessionRepository
+	session *models.Session
+}
+
+func (s *Suite) SetupSuite() {
+	var (
+		db  *sql.DB
+		err error
+	)
+
+	// TODO: make it work with gorm.io/gorm, currently only works with jinzhu/gorm (gorm V1)
+	db, s.mock, err = sqlmock.New()
+	// db, s.mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
+
+	require.NoError(s.T(), err)
+
+	s.db, err = gorm.Open(postgres.New(postgres.Config{
+		Conn: db,
+	}), &gorm.Config{})
+
+	require.NoError(s.T(), err)
+
+	s.repo = NewSessionRepository(s.db)
+}
+
+func (s *Suite) AfterTest(_, _ string) {
+	require.NoError(s.T(), s.mock.ExpectationsWereMet())
+}
+
+func TestInit(t *testing.T) {
+	suite.Run(t, new(Suite))
+}
+
+func (s *Suite) TestShouldCreateNewSession() {
+	var (
+		key       = "onekey"
+		data      = []byte("onedata")
+		expiresAt = time.Now()
+	)
+
+	rows := sqlmock.NewRows([]string{"id"}).AddRow("111")
+
+	s.mock.ExpectBegin()
+	// s.mock.ExpectQuery(`INSERT INTO "sessions" ("created_at","updated_at","deleted_at","key","data","expires_at")
+	// 	VALUES ($1,$2,$3,$4,$5,$6) RETURNING "sessions"."id"`).
+	s.mock.ExpectQuery(`.*`).
+		WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), key, data, expiresAt).
+		WillReturnRows(rows)
+	s.mock.ExpectCommit()
+
+	// test function
+	_, err := s.repo.CreateSession(&models.Session{
+		Key:       key,
+		Data:      data,
+		ExpiresAt: expiresAt,
+	})
+
+	require.NoError(s.T(), err)
+}
+
+func (s *Suite) TestShoudSelectSessionByKey() {
+	var (
+		key = "onekey"
+	)
+
+	rows := sqlmock.NewRows([]string{"Key"}).AddRow(key)
+
+	s.mock.ExpectQuery(`.*`). // do proper regex labor later as meditative exercise
+					WithArgs(key).
+					WillReturnRows(rows)
+
+	// test function
+	res, err := s.repo.SelectSession(&models.Session{
+		Key: key,
+	})
+
+	require.NoError(s.T(), err)
+	require.Nil(s.T(), deep.Equal(&models.Session{Key: key}, res))
+}
+
+func (s *Suite) TestShouldUpdateSessionByKey() {
+	var (
+		key       = "onekey"
+		data      = []byte("chobanilime")
+		expiresAt = time.Now()
+	)
+
+	// rows := sqlmock.NewRows([]string{"Key"}).AddRow(key)
+
+	s.mock.ExpectBegin()
+	s.mock.ExpectExec(`.*`). // do proper regex labor later as meditative exercise
+					WithArgs(sqlmock.AnyArg(), key, data, sqlmock.AnyArg(), key).
+					WillReturnResult(sqlmock.NewResult(1, 1))
+	s.mock.ExpectCommit()
+
+	// test function
+	_, err := s.repo.UpdateSession(&models.Session{
+		Key:       key,
+		Data:      data,
+		ExpiresAt: expiresAt,
+	})
+
+	require.NoError(s.T(), err)
+	// require.Nil(s.T(), deep.Equal(&models.Session{Data: data}, res))
+}
+
+func (s *Suite) TestShouldDeleteSession() {
+	var (
+		key = "onekey"
+	)
+
+	// rows := sqlmock.NewRows([]string{"id"}).AddRow("111")
+
+	s.mock.ExpectBegin()
+	s.mock.ExpectExec(`.*`).
+		WithArgs(key).
+		WillReturnResult(sqlmock.NewResult(1, 1))
+	s.mock.ExpectCommit()
+
+	// test function
+	_, err := s.repo.DeleteSession(&models.Session{
+		Key: key,
+	})
+
+	require.NoError(s.T(), err)
+}

+ 77 - 0
internal/repository/gorm/user.go

@@ -0,0 +1,77 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+// UserRepository uses gorm.DB for querying the database
+type UserRepository struct {
+	db *gorm.DB
+}
+
+// NewUserRepository returns a DefaultUserRepository which uses
+// gorm.DB for querying the database
+func NewUserRepository(db *gorm.DB) repository.UserRepository {
+	return &UserRepository{db}
+}
+
+// CreateUser adds a new User row to the Users table in the database
+func (repo *UserRepository) CreateUser(user *models.User) (*models.User, error) {
+	if err := repo.db.Create(user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// ReadUser finds a single user based on their unique id
+func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
+	user := &models.User{}
+	if err := repo.db.Where("id = ?", id).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// ReadUserByEmail finds a single user based on their unique email
+func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
+	user := &models.User{}
+	if err := repo.db.Where("email = ?", email).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// UpdateUser modifies an existing User in the database
+func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
+	if err := repo.db.First(&models.User{}, user.ID).Updates(user).Error; err != nil {
+		return nil, err
+	}
+
+	return user, nil
+}
+
+// DeleteUser deletes a single user using their unique id
+func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error) {
+	if err := repo.db.First(&models.User{}, user.ID).Delete(&user).Error; err != nil {
+		return nil, err
+	}
+	return user, nil
+}
+
+// CheckPassword checks the input password is correct for the provided user id.
+func (repo *UserRepository) CheckPassword(id int, pwd string) (bool, error) {
+	u := &models.User{}
+
+	if err := repo.db.First(u, id).Error; err != nil {
+		return false, err
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(pwd)); err != nil {
+		return false, err
+	}
+
+	return true, nil
+}

+ 7 - 0
internal/repository/repository.go

@@ -0,0 +1,7 @@
+package repository
+
+// Repository collects the repositories for each model
+type Repository struct {
+	User    UserRepository
+	Session SessionRepository
+}

+ 13 - 0
internal/repository/session.go

@@ -0,0 +1,13 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// SessionRepository represents the set of queries on the Session model
+type SessionRepository interface {
+	CreateSession(session *models.Session) (*models.Session, error)
+	UpdateSession(session *models.Session) (*models.Session, error)
+	DeleteSession(session *models.Session) (*models.Session, error)
+	SelectSession(session *models.Session) (*models.Session, error)
+}

+ 14 - 0
internal/repository/test/repository.go

@@ -0,0 +1,14 @@
+package test
+
+import (
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// NewRepository returns a Repository which persists users in memory
+// and accepts a parameter that can trigger read/write errors
+func NewRepository(canQuery bool) *repository.Repository {
+	return &repository.Repository{
+		User:    NewUserRepository(canQuery),
+		Session: NewSessionRepository(canQuery),
+	}
+}

+ 95 - 0
internal/repository/test/session.go

@@ -0,0 +1,95 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// SessionRepository uses gorm.DB for querying the database
+type SessionRepository struct {
+	canQuery bool
+	sessions []*models.Session
+}
+
+// NewSessionRepository returns pointer to repo along with the db
+func NewSessionRepository(canQuery bool) repository.SessionRepository {
+	return &SessionRepository{canQuery, []*models.Session{}}
+}
+
+// CreateSession must take in Key, Data, and ExpiresAt as arguments.
+func (repo *SessionRepository) CreateSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	// make sure key doesn't exist
+	for _, s := range repo.sessions {
+		if s.Key == session.Key {
+			return nil, errors.New("Cannot write database")
+		}
+	}
+
+	sessions := repo.sessions
+	sessions = append(sessions, session)
+	repo.sessions = sessions
+	session.ID = uint(len(repo.sessions))
+
+	return session, nil
+}
+
+// UpdateSession updates only the Data field using Key as selector.
+func (repo *SessionRepository) UpdateSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	var oldSession *models.Session
+
+	for _, s := range repo.sessions {
+		if s.Key == session.Key {
+			oldSession = s
+		}
+	}
+
+	if oldSession != nil {
+		oldSession.Data = session.Data
+
+		return oldSession, nil
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// DeleteSession deletes a session by Key
+func (repo *SessionRepository) DeleteSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(session.ID-1) >= len(repo.sessions) || repo.sessions[session.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(session.ID - 1)
+	repo.sessions[index] = nil
+
+	return session, nil
+}
+
+// SelectSession returns a session with matching key
+func (repo *SessionRepository) SelectSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	for _, s := range repo.sessions {
+		if s.Key == session.Key {
+			return s, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}

+ 127 - 0
internal/repository/test/user.go

@@ -0,0 +1,127 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+// UserRepository will return errors on queries if canQuery is false
+// and only stores a small set of users in-memory that are indexed by their
+// array index + 1
+type UserRepository struct {
+	canQuery bool
+	users    []*models.User
+}
+
+// NewUserRepository will return errors
+func NewUserRepository(canQuery bool) repository.UserRepository {
+	return &UserRepository{canQuery, []*models.User{}}
+}
+
+// CreateUser adds a new User row to the Users table in array memory
+func (repo *UserRepository) CreateUser(user *models.User) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	// make sure email doesn't exist
+	for _, u := range repo.users {
+		if u.Email == user.Email {
+			return nil, errors.New("Cannot write database")
+		}
+	}
+
+	users := repo.users
+	users = append(users, user)
+	repo.users = users
+	user.ID = uint(len(repo.users))
+	return user, nil
+}
+
+// ReadUser finds a single user based on their unique id
+func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.users) || repo.users[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.users[index], nil
+}
+
+// ReadUserByEmail finds a single user based on their unique email
+func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, u := range repo.users {
+		if u.Email == email {
+			return u, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// UpdateUser modifies an existing User in the database
+func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(user.ID - 1)
+	oldUser := *repo.users[index]
+	repo.users[index] = user
+	user.Email = oldUser.Email
+	user.Password = oldUser.Password
+
+	return user, nil
+}
+
+// DeleteUser deletes a single user using their unique id
+func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(user.ID - 1)
+	repo.users[index] = nil
+
+	return user, nil
+}
+
+// CheckPassword checks the input password is correct for the provided user id.
+func (repo *UserRepository) CheckPassword(id int, pwd string) (bool, error) {
+	if !repo.canQuery {
+		return false, errors.New("Cannot write database")
+	}
+
+	if int(id-1) >= len(repo.users) || repo.users[id-1] == nil {
+		return false, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	user := *repo.users[index]
+
+	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(pwd)); err != nil {
+		return false, err
+	}
+
+	return true, nil
+}

+ 18 - 0
internal/repository/user.go

@@ -0,0 +1,18 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// WriteUser is the function type for all User write operations
+type WriteUser func(user *models.User) (*models.User, error)
+
+// UserRepository represents the set of queries on the User model
+type UserRepository interface {
+	CreateUser(user *models.User) (*models.User, error)
+	CheckPassword(id int, pwd string) (bool, error)
+	ReadUser(id uint) (*models.User, error)
+	ReadUserByEmail(email string) (*models.User, error)
+	UpdateUser(user *models.User) (*models.User, error)
+	DeleteUser(user *models.User) (*models.User, error)
+}

+ 8 - 4
server/api/api.go

@@ -4,24 +4,27 @@ import (
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
 	"github.com/go-playground/validator/v10"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
 	lr "github.com/porter-dev/porter/internal/logger"
-	"gorm.io/gorm"
+	"github.com/porter-dev/porter/internal/repository"
 )
 
 // App represents an API instance with handler methods attached, a DB connection
 // and a logger instance
 type App struct {
 	logger     *lr.Logger
-	db         *gorm.DB
+	repo       *repository.Repository
 	validator  *validator.Validate
+	store      *sessionstore.PGStore
 	translator *ut.Translator
 }
 
 // New returns a new App instance
 func New(
 	logger *lr.Logger,
-	db *gorm.DB,
+	repo *repository.Repository,
 	validator *validator.Validate,
+	store *sessionstore.PGStore,
 ) *App {
 	// for now, will just support the english translator from the
 	// validator/translations package
@@ -31,8 +34,9 @@ func New(
 
 	return &App{
 		logger:     logger,
-		db:         db,
+		repo:       repo,
 		validator:  validator,
+		store:      store,
 		translator: &trans,
 	}
 }

+ 66 - 44
server/api/errors.go

@@ -2,15 +2,10 @@ package api
 
 import (
 	"encoding/json"
-	"fmt"
 	"net/http"
 
 	"github.com/go-playground/validator/v10"
-)
-
-const (
-	appErrDataWrite    = "data write error"
-	appErrFormDecoding = "could not process JSON body"
+	"gorm.io/gorm"
 )
 
 // HTTPError is the object returned when the API encounters an error: this
@@ -23,11 +18,30 @@ type HTTPError struct {
 // ErrorCode is a custom Porter error code, useful for frontend messages
 type ErrorCode int64
 
-// Enumeration of API error codes, represented as int64
-const (
-	ErrUserDecode ErrorCode = iota
-	ErrUserValidateFields
-	ErrUserDataWrite
+var (
+	// ErrorDataWrite describes an error in writing to the database
+	ErrorDataWrite = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"could not write to database",
+		},
+	}
+
+	// ErrorDataRead describes an error when reading from the database
+	ErrorDataRead = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"could not read from database",
+		},
+	}
+
+	// ErrorInternal describes a generic internal server error
+	ErrorInternal = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"internal server error",
+		},
+	}
 )
 
 // ------------------------ Error helper functions ------------------------ //
@@ -37,21 +51,21 @@ const (
 // client.
 //
 // It then logs it via the app.logger and sends a formatted error to the client.
-func (app *App) sendExternalError(err error, errExt HTTPError, w http.ResponseWriter) (intErr error) {
-	respBytes, newErr := json.Marshal(errExt)
-
-	if newErr != nil {
-		app.handleErrorInternalError(newErr, w)
-		return newErr
-	}
-
+func (app *App) sendExternalError(
+	err error,
+	code int,
+	errExt HTTPError,
+	w http.ResponseWriter,
+) (intErr error) {
+	respBytes, _ := json.Marshal(errExt)
 	respBody := string(respBytes)
 
 	app.logger.Warn().Err(err).
 		Str("errExt", respBody).
 		Msg("")
 
-	fmt.Fprintf(w, respBody)
+	w.WriteHeader(code)
+	w.Write(respBytes)
 
 	return nil
 }
@@ -62,14 +76,10 @@ func (app *App) sendExternalError(err error, errExt HTTPError, w http.ResponseWr
 func (app *App) handleErrorFormDecoding(err error, code ErrorCode, w http.ResponseWriter) {
 	errExt := HTTPError{
 		Code:   code,
-		Errors: []string{appErrFormDecoding},
+		Errors: []string{"could not process request"},
 	}
 
-	intErr := app.sendExternalError(err, errExt, w)
-
-	if intErr == nil {
-		w.WriteHeader(http.StatusUnprocessableEntity)
-	}
+	app.sendExternalError(err, http.StatusBadRequest, errExt, w)
 }
 
 // handleErrorFormValidation handles an error in the validation of form fields, and
@@ -90,31 +100,43 @@ func (app *App) handleErrorFormValidation(err error, code ErrorCode, w http.Resp
 		Errors: res,
 	}
 
-	intErr := app.sendExternalError(err, errExt, w)
-
-	if intErr == nil {
-		w.WriteHeader(http.StatusUnprocessableEntity)
-	}
+	app.sendExternalError(err, http.StatusUnprocessableEntity, errExt, w)
 }
 
-// handleErrorDataWrite handles a database write error
-func (app *App) handleErrorDataWrite(err error, code ErrorCode, w http.ResponseWriter) {
-	errExt := HTTPError{
-		Code:   code,
-		Errors: []string{appErrDataWrite},
+// handleErrorRead handles an error in reading a record from the DB. If the record is
+// not found, the error message is more descriptive; otherwise, a generic dataRead
+// error is sent.
+func (app *App) handleErrorRead(err error, code ErrorCode, w http.ResponseWriter) {
+	// first check if the error is RecordNotFound -- send a more descriptive
+	// message if that is the case
+	if err == gorm.ErrRecordNotFound {
+		errExt := HTTPError{
+			Code:   code,
+			Errors: []string{"could not find requested object"},
+		}
+
+		app.sendExternalError(err, http.StatusNotFound, errExt, w)
+
+		return
 	}
 
-	intErr := app.sendExternalError(err, errExt, w)
+	app.handleErrorDataRead(err, code, w)
+}
 
-	if intErr == nil {
-		w.WriteHeader(http.StatusUnprocessableEntity)
-	}
+// handleErrorDataWrite handles a database write error due to either a connection
+// error with the database or failure to write that wasn't caught by the validators
+func (app *App) handleErrorDataWrite(err error, w http.ResponseWriter) {
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataWrite, w)
+}
+
+// handleErrorDataRead handles a database read error due to an internal error, such as
+// the database connection or gorm internals
+func (app *App) handleErrorDataRead(err error, code ErrorCode, w http.ResponseWriter) {
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataRead, w)
 }
 
 // handleErrorInternalError is a catch-all for internal errors that occur during the
 // processing of a request
-func (app *App) handleErrorInternalError(err error, w http.ResponseWriter) {
-	app.logger.Warn().Err(err).Msg("")
-	w.WriteHeader(http.StatusInternalServerError)
-	fmt.Fprintf(w, `{"error": "Internal server error"}`)
+func (app *App) handleErrorInternal(err error, w http.ResponseWriter) {
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorInternal, w)
 }

+ 264 - 44
server/api/user_handler.go

@@ -2,87 +2,307 @@ package api
 
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
+	"strconv"
+	"strings"
 
-	"github.com/porter-dev/porter/internal/queries"
+	"github.com/porter-dev/porter/internal/kubernetes"
 
+	"gorm.io/gorm"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/bcrypt"
+)
+
+// Enumeration of user API error codes, represented as int64
+const (
+	ErrUserDecode ErrorCode = iota + 600
+	ErrUserValidateFields
+	ErrUserDataRead
 )
 
 // HandleCreateUser validates a user form entry, converts the user to a gorm
 // model, and saves the user to the database
 func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
-	form := &models.CreateUserForm{}
+	form := &forms.CreateUserForm{}
+
+	user, err := app.writeUser(
+		form,
+		app.repo.User.CreateUser,
+		w,
+		r,
+		doesUserExist,
+	)
+
+	if err == nil {
+		app.logger.Info().Msgf("New user created: %d", user.ID)
+		w.WriteHeader(http.StatusCreated)
+	}
+}
 
+// HandleLoginUser checks the request header for cookie and validates the user.
+func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
+	session, _ := app.store.Get(r, "cookie-name")
+	form := &forms.LoginUserForm{}
+
+	// decode from JSON to form value
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
 
-	if err := app.validator.Struct(form); err != nil {
-		app.handleErrorFormValidation(err, ErrUserValidateFields, w)
+	storedUser, readErr := app.repo.User.ReadUserByEmail(form.Email)
+
+	if readErr != nil {
+		app.sendExternalError(readErr, http.StatusUnauthorized, HTTPError{
+			Errors: []string{"email not registered"},
+			Code:   http.StatusUnauthorized,
+		}, w)
+
 		return
 	}
 
-	userModel, err := form.ToUser()
+	if err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(form.Password)); err != nil {
+		app.sendExternalError(readErr, http.StatusUnauthorized, HTTPError{
+			Errors: []string{"incorrect password"},
+			Code:   http.StatusUnauthorized,
+		}, w)
 
+		return
+	}
+
+	// Set user as authenticated
+	session.Values["authenticated"] = true
+	session.Save(r, w)
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleReadUser returns an externalized User (models.UserExternal)
+// based on an ID
+func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
+	user, err := app.readUser(w, r)
+
+	// error already handled by helper
 	if err != nil {
+		return
+	}
+
+	extUser := user.Externalize()
+
+	if err := json.NewEncoder(w).Encode(extUser); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
 
-	user, err := queries.CreateUser(app.db, userModel)
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleReadUserClusters returns the externalized User.Clusters (models.ClusterConfigs)
+// based on a user ID
+func (app *App) HandleReadUserClusters(w http.ResponseWriter, r *http.Request) {
+	user, err := app.readUser(w, r)
 
+	// error already handled by helper
 	if err != nil {
-		app.handleErrorDataWrite(err, ErrUserDataWrite, w)
 		return
 	}
 
-	app.logger.Info().Msgf("New user created: %d", user.ID)
+	extClusters := make([]models.ClusterConfigExternal, 0)
 
-	w.WriteHeader(http.StatusCreated)
-}
+	for _, cluster := range user.Clusters {
+		extClusters = append(extClusters, *cluster.Externalize())
+	}
+
+	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
 
-// HandleReadUser is majestic
-func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
-	w.Write([]byte("{}"))
 }
 
-// HandleUpdateUser is majestic
+// HandleReadUserClustersAll returns all models.ClusterConfigs parsed from a KubeConfig
+// that is attached to a specific user, identified through the user ID
+func (app *App) HandleReadUserClustersAll(w http.ResponseWriter, r *http.Request) {
+	user, err := app.readUser(w, r)
+
+	// if there is an error, it's already handled by helper
+	if err == nil {
+		clusters, err := kubernetes.GetAllClusterConfigsFromBytes(user.RawKubeConfig)
+
+		if err != nil {
+			app.handleErrorFormDecoding(err, ErrUserDecode, w)
+			return
+		}
+
+		extClusters := make([]models.ClusterConfigExternal, 0)
+
+		for _, cluster := range clusters {
+			extClusters = append(extClusters, *cluster.Externalize())
+		}
+
+		if err := json.NewEncoder(w).Encode(extClusters); err != nil {
+			app.handleErrorFormDecoding(err, ErrUserDecode, w)
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+	}
+}
+
+// HandleUpdateUser validates an update user form entry, updates the user
+// in the database, and writes status accepted
 func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
-	w.WriteHeader(http.StatusAccepted)
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	form := &forms.UpdateUserForm{
+		ID: uint(id),
+	}
+
+	user, err := app.writeUser(form, app.repo.User.UpdateUser, w, r)
+
+	if err == nil {
+		app.logger.Info().Msgf("User updated: %d", user.ID)
+		w.WriteHeader(http.StatusNoContent)
+	}
 }
 
-// HandleDeleteUser is majestic
+// HandleDeleteUser removes a user after checking that the sent password is correct
 func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
-	w.WriteHeader(http.StatusAccepted)
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	// TODO -- HASH AND VERIFY PASSWORD BEFORE USER DELETION
+	form := &forms.DeleteUserForm{
+		ID: uint(id),
+	}
+
+	user, err := app.writeUser(form, app.repo.User.DeleteUser, w, r)
+
+	if err == nil {
+		app.logger.Info().Msgf("User deleted: %d", user.ID)
+		w.WriteHeader(http.StatusNoContent)
+	}
 }
 
-// GenerateUser creates a new user based on a unique ID and a kubeconfig
-// func GenerateUser(id string, kubeconfig []byte) *User {
-// 	conf := kubernetes.KubeConfig{}
-
-// 	err := yaml.Unmarshal(kubeconfig, &conf)
-
-// 	// TODO -- HANDLE ERROR
-// 	if err != nil {
-// 		fmt.Println("ERROR IN UNMARSHALING")
-// 	}
-
-// 	// generate the user's clusters
-// 	clusters := conf.ToClusterConfigs()
-
-// 	return &User{
-// 		ID:            id,
-// 		Clusters:      clusters,
-// 		RawKubeConfig: kubeconfig,
-// 	}
-// }
-
-// // printUser is a helper function to print a user's config without sensitive information
-// func (u *User) printUser() {
-// 	for _, cluster := range u.Clusters {
-// 		fmt.Println(cluster.Name, cluster.Context, cluster.Server, cluster.User)
-// 	}
-// }
+// ------------------------ User handler helper functions ------------------------ //
+
+// writeUser will take a POST or PUT request to the /api/users endpoint and decode
+// the request into a forms.WriteUserForm model, convert it to a models.User, and
+// write to the database.
+func (app *App) writeUser(
+	form forms.WriteUserForm,
+	dbWrite repository.WriteUser,
+	w http.ResponseWriter,
+	r *http.Request,
+	validators ...func(repo *repository.Repository, user *models.User) *HTTPError,
+) (*models.User, error) {
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return nil, err
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrUserValidateFields, w)
+		return nil, err
+	}
+
+	// convert the form to a user model -- WriteUserForm must implement ToUser
+	userModel, err := form.ToUser()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return nil, err
+	}
+
+	// Check any additional validators for any semantic errors
+	// We have completed all syntax checks, so these will be sent
+	// with http.StatusUnprocessableEntity (422), unless this is
+	// an internal server error
+	for _, validator := range validators {
+		err := validator(app.repo, userModel)
+
+		if err != nil {
+			goErr := errors.New(strings.Join(err.Errors, ", "))
+			if err.Code == 500 {
+				app.sendExternalError(
+					goErr,
+					http.StatusInternalServerError,
+					*err,
+					w,
+				)
+			} else {
+				app.sendExternalError(
+					goErr,
+					http.StatusUnprocessableEntity,
+					*err,
+					w,
+				)
+			}
+
+			return nil, goErr
+		}
+	}
+
+	// handle write to the database
+	user, err := dbWrite(userModel)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return nil, err
+	}
+
+	return user, nil
+}
+
+func (app *App) readUser(w http.ResponseWriter, r *http.Request) (*models.User, error) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return nil, err
+	}
+
+	user, err := app.repo.User.ReadUser(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrUserDataRead, w)
+		return nil, err
+	}
+
+	return user, nil
+}
+
+func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
+	user, err := repo.User.ReadUserByEmail(user.Email)
+
+	if user != nil && err == nil {
+		return &HTTPError{
+			Code: ErrUserValidateFields,
+			Errors: []string{
+				"email already taken",
+			},
+		}
+	}
+
+	if err != gorm.ErrRecordNotFound {
+		return &ErrorDataRead
+	}
+
+	return nil
+}

+ 583 - 0
server/api/user_handler_test.go

@@ -0,0 +1,583 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/test"
+	"github.com/porter-dev/porter/server/api"
+	"github.com/porter-dev/porter/server/router"
+
+	sessionstore "github.com/porter-dev/porter/internal/auth"
+	lr "github.com/porter-dev/porter/internal/logger"
+	vr "github.com/porter-dev/porter/internal/validator"
+)
+
+func initApi(canQuery bool) (*api.App, *repository.Repository) {
+	appConf := config.Conf{
+		Debug: true,
+		Server: config.ServerConf{
+			Port:         8080,
+			TimeoutRead:  time.Second * 5,
+			TimeoutWrite: time.Second * 10,
+			TimeoutIdle:  time.Second * 15,
+		},
+		// unimportant
+		Db: config.DBConf{},
+	}
+
+	logger := lr.NewConsole(appConf.Debug)
+	validator := vr.New()
+
+	repo := test.NewRepository(canQuery)
+
+	key := []byte("secret") // TODO: change to os.Getenv("SESSION_KEY")
+	store, _ := sessionstore.NewStore(repo, key)
+
+	return api.New(logger, repo, validator, store), repo
+}
+
+func testUserRequest(t *testing.T, c userTest) {
+	// create a mock API
+	api, repo := initApi(c.canQuery)
+	r := router.New(api)
+
+	if c.init != nil {
+		c.init(repo)
+	}
+
+	req, err := http.NewRequest(
+		c.method,
+		c.endpoint,
+		strings.NewReader(c.body),
+	)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+	r.ServeHTTP(rr, req)
+
+	// first, check that the status matches
+	if status := rr.Code; status != c.expStatus {
+		t.Errorf("%s, handler returned wrong status code: got %v want %v",
+			c.msg, status, c.expStatus)
+	}
+
+	// if there's a validator, call it
+	for _, validate := range c.validators {
+		validate(rr, c, r, t)
+	}
+}
+
+type userTest struct {
+	init func(repo *repository.Repository)
+	msg,
+	method,
+	endpoint,
+	body string
+	expStatus  int
+	expBody    string
+	canQuery   bool
+	validators []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T)
+}
+
+var createUserTests = []userTest{
+	userTest{
+		msg:      "Create user",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   "",
+		canQuery:  true,
+	},
+	userTest{
+		msg:      "Create user invalid email",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "notanemail",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["email validation failed"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		msg:      "Create user missing field",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"password": "hello"
+		}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["required validation failed"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		msg:      "Create user db connection down",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusInternalServerError,
+		expBody:   `{"code":500,"errors":["could not read from database"]}`,
+		canQuery:  false,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:      "Create user same email",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["email already taken"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		msg:      "Create user invalid field type",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": 0
+		}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateUser(t *testing.T) {
+	for _, c := range createUserTests {
+		testUserRequest(t, c)
+	}
+}
+
+var readUserTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+				Clusters: []models.ClusterConfig{
+					models.ClusterConfig{
+						Name:    "cluster-test",
+						Server:  "https://localhost",
+						Context: "context-test",
+						User:    "test-admin",
+					},
+				},
+				RawKubeConfig: []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"),
+			})
+		},
+		msg:       "Read user successful",
+		method:    "GET",
+		endpoint:  "/api/users/1",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev","clusters":[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			UserModelBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Read user bad id field",
+		method:    "GET",
+		endpoint:  "/api/users/aldkfjas",
+		body:      "",
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Read user not found",
+		method:    "GET",
+		endpoint:  "/api/users/2",
+		body:      "",
+		expStatus: http.StatusNotFound,
+		expBody:   `{"code":602,"errors":["could not find requested object"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleReadUser(t *testing.T) {
+	for _, c := range readUserTests {
+		testUserRequest(t, c)
+	}
+}
+
+var readUserClustersTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+				Clusters: []models.ClusterConfig{
+					models.ClusterConfig{
+						Name:    "cluster-test",
+						Server:  "https://localhost",
+						Context: "context-test",
+						User:    "test-admin",
+					},
+				},
+				RawKubeConfig: []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"),
+			})
+		},
+		msg:       "Read user successful",
+		method:    "GET",
+		endpoint:  "/api/users/1/clusters",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}]`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			ClusterBodyValidator,
+		},
+	},
+}
+
+func TestHandleReadUserClusters(t *testing.T) {
+	for _, c := range readUserClustersTests {
+		testUserRequest(t, c)
+	}
+}
+
+var readUserClustersAllTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:         "belanger@getporter.dev",
+				Password:      "hello",
+				Clusters:      []models.ClusterConfig{},
+				RawKubeConfig: []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"),
+			})
+		},
+		msg:       "Read user successful",
+		method:    "GET",
+		endpoint:  "/api/users/1/clusters/all",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}]`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			ClusterBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:         "belanger@getporter.dev",
+				Password:      "hello",
+				Clusters:      []models.ClusterConfig{},
+				RawKubeConfig: []byte("apiVersion: \xc5\n"),
+			})
+		},
+		msg:       "Read user with invalid utf-8 \xc5 in kubeconfig",
+		method:    "GET",
+		endpoint:  "/api/users/1/clusters/all",
+		body:      "",
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleReadUserClustersAll(t *testing.T) {
+	for _, c := range readUserClustersAllTests {
+		testUserRequest(t, c)
+	}
+}
+
+var updateUserTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user successful",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				r.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","clusters":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user invalid id",
+		method:    "PUT",
+		endpoint:  "/api/users/alsdfjk",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user bad kubeconfig",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"notvalidyaml", "allowedClusters":[]}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user db connection down",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		expStatus: http.StatusInternalServerError,
+		expBody:   `{"code":500,"errors":["could not write to database"]}`,
+		canQuery:  false,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleUpdateUser(t *testing.T) {
+	for _, c := range updateUserTests {
+		testUserRequest(t, c)
+	}
+}
+
+var deleteUserTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Delete user successful",
+		method:    "DELETE",
+		endpoint:  "/api/users/1",
+		body:      `{"password":"hello"}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+
+				r.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				if status := rr2.Code; status != 404 {
+					t.Errorf("DELETE user validation, handler returned wrong status code: got %v want %v",
+						status, 404)
+				}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"code":602,"errors":["could not find requested object"]}`), expBody)
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Delete user invalid id",
+		method:    "DELETE",
+		endpoint:  "/api/users/aldkjf",
+		body:      `{"password":"hello"}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Delete user missing password",
+		method:    "DELETE",
+		endpoint:  "/api/users/1",
+		body:      `{}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["required validation failed"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleDeleteUser(t *testing.T) {
+	for _, c := range deleteUserTests {
+		testUserRequest(t, c)
+	}
+}
+
+func BasicBodyValidator(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+	if body := rr.Body.String(); body != c.expBody {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, body, c.expBody)
+	}
+}
+
+func UserModelBodyValidator(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+	gotBody := &models.UserExternal{}
+	expBody := &models.UserExternal{}
+
+	json.Unmarshal(rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}
+
+func ClusterBodyValidator(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+	// if status is expected to be 200, parse the body for UserExternal
+	gotBody := &[]models.ClusterConfigExternal{}
+	expBody := &[]models.ClusterConfigExternal{}
+
+	json.Unmarshal(rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}

+ 37 - 0
server/router/middleware/auth.go

@@ -0,0 +1,37 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/gorilla/sessions"
+)
+
+var (
+	key   = []byte("secret")             // change to os.Getenv("SESSION_KEY")
+	store = sessions.NewCookieStore(key) // Swap out with custom store
+)
+
+// Authenticate is middleware for authentication
+func Authenticate(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if isLoggedIn(r) {
+			next.ServeHTTP(w, r)
+		} else {
+			http.Error(w, http.StatusText(403), 403)
+			return
+		}
+
+		return
+	})
+}
+
+// Helpers
+
+func isLoggedIn(r *http.Request) bool {
+	session, _ := store.Get(r, "session-id")
+
+	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
+		return false
+	}
+	return true
+}

+ 6 - 1
server/router/router.go

@@ -16,8 +16,13 @@ func New(a *api.App) *chi.Mux {
 		r.Use(middleware.ContentTypeJSON)
 
 		// /api/users routes
+		r.Method("GET", "/users/{id}", requestlog.NewHandler(a.HandleReadUser, l))
+		r.Method("GET", "/users/{id}/clusters", requestlog.NewHandler(a.HandleReadUserClusters, l))
+		r.Method("GET", "/users/{id}/clusters/all", requestlog.NewHandler(a.HandleReadUserClustersAll, l))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("GET", "/users", requestlog.NewHandler(a.HandleReadUser, l))
+		r.Method("PUT", "/users/{id}", requestlog.NewHandler(a.HandleUpdateUser, l))
+		r.Method("DELETE", "/users/{id}", requestlog.NewHandler(a.HandleDeleteUser, l))
+		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 	})
 
 	return r