diff --git a/.github/compute-version/action.yaml b/.github/compute-version/action.yaml
new file mode 100644
index 00000000..0ab9913f
--- /dev/null
+++ b/.github/compute-version/action.yaml
@@ -0,0 +1,16 @@
+name: Compute Version
+description: computes version of tornjak
+outputs:
+ version:
+ description: "tornjak version"
+ value: ${{ steps.version.outputs.version }}
+runs:
+ using: composite
+ steps:
+ - name: Generate Version
+ id: version
+ shell: bash
+ run: |
+ version="$(cat version.txt | cut -d '.' -f -2)"
+ echo VERSION=$version
+ echo "version=$version" >> $GITHUB_OUTPUT
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 9a8e9627..b9cbbb92 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -15,10 +15,10 @@ jobs:
EOF
- name: Check out repository code
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.6
- name: Install Golang
- uses: actions/setup-go@v5.0.0
+ uses: actions/setup-go@v5.0.1
with:
go-version-file: go.mod
check-latest: true
@@ -39,7 +39,7 @@ jobs:
run: go mod download
- name: golangci-lint
- uses: golangci/golangci-lint-action@v4.0.0
+ uses: golangci/golangci-lint-action@v6.0.1
with:
version: v1.57.2
args: --timeout 7m
diff --git a/.github/workflows/master-build.yaml b/.github/workflows/master-build.yaml
index 1579deff..b70daa04 100644
--- a/.github/workflows/master-build.yaml
+++ b/.github/workflows/master-build.yaml
@@ -19,9 +19,10 @@ jobs:
EOF
- name: Check out repository code
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.6
+
- name: Log in to GHCR.io
- uses: docker/login-action@v3.1.0
+ uses: docker/login-action@v3.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -30,11 +31,15 @@ jobs:
- name: Get branch name
id: branch_name
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
+
+ - name: Compute Tornjak version
+ uses: ./.github/compute-version
+ id: version
- name: Run build
uses: ./.github/actions/build
with:
- tag-version: ${{ contains(fromJSON('["main", "v1.6"]'), steps.branch_name.outputs.branch) && true || false }}
+ tag-version: ${{ contains(fromJSON('["main", "${{ steps.version.outputs.version }}"]'), steps.branch_name.outputs.branch) && true || false }}
- name: Print job result
run: |
@@ -55,9 +60,9 @@ jobs:
EOF
- name: Check out repository code
- uses: actions/checkout@v4.1.2
+ uses: actions/checkout@v4.1.6
- name: Log in to GHCR.io
- uses: docker/login-action@v3.1.0
+ uses: docker/login-action@v3.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -67,13 +72,17 @@ jobs:
id: branch_name
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
+ - name: Compute Tornjak version
+ uses: ./.github/compute-version
+ id: version
+
- name: Run build
uses: ./.github/actions/build
with:
image-tag-prefix: ubi-
backend-dockerfile: Dockerfile.backend-container.ubi
frontend-dockerfile: frontend/Dockerfile.frontend-container.ubi
- tag-version: ${{ contains(fromJSON('["main", "v1.6"]'), steps.branch_name.outputs.branch) && true || false }}
+ tag-version: ${{ contains(fromJSON('["main", "${{ steps.version.outputs.version }}"]'), steps.branch_name.outputs.branch) && true || false }}
- name: Print job result
run: |
diff --git a/api/agent/server.go b/api/agent/server.go
index 5d8dd09a..3d647f3f 100644
--- a/api/agent/server.go
+++ b/api/agent/server.go
@@ -20,8 +20,9 @@ import (
"github.com/hashicorp/hcl/hcl/token"
"github.com/pkg/errors"
- auth "github.com/spiffe/tornjak/pkg/agent/auth"
agentdb "github.com/spiffe/tornjak/pkg/agent/db"
+ "github.com/spiffe/tornjak/pkg/agent/authentication/authenticator"
+ "github.com/spiffe/tornjak/pkg/agent/authorization"
)
type Server struct {
@@ -36,7 +37,8 @@ type Server struct {
// Plugins
Db agentdb.AgentDB
- Auth auth.Auth
+ Authenticator authenticator.Authenticator
+ Authorizer authorization.Authorizer
}
// config type, as defined by SPIRE
@@ -429,15 +431,18 @@ func (s *Server) verificationMiddleware(next http.Handler) http.Handler {
cors(w, r)
return
}
- err := s.Auth.Verify(r)
+
+ userInfo := s.Authenticator.AuthenticateRequest(r)
+
+ err := s.Authorizer.AuthorizeRequest(r, userInfo)
if err != nil {
emsg := fmt.Sprintf("Error authorizing request: %v", err.Error())
// error should be written already
retError(w, emsg, http.StatusUnauthorized)
return
- } else {
- next.ServeHTTP(w, r)
}
+
+ next.ServeHTTP(w, r)
}
return http.HandlerFunc(f)
}
@@ -540,41 +545,61 @@ func (s *Server) home(w http.ResponseWriter, r *http.Request) {
}
}
+func (s *Server) health(w http.ResponseWriter, r *http.Request) {
+ var ret = "Endpoint is healthy."
+
+ cors(w, r)
+ je := json.NewEncoder(w)
+
+ var err = je.Encode(ret)
+ if err != nil {
+ emsg := fmt.Sprintf("Error: %v", err.Error())
+ retError(w, emsg, http.StatusBadRequest)
+ }
+}
+
+
func (s *Server) GetRouter() http.Handler {
rtr := mux.NewRouter()
+ apiRtr := rtr.PathPrefix("/").Subrouter()
+ healthRtr := rtr.PathPrefix("/healthz").Subrouter()
+
+ // Healthcheck (never goes through authn/authz layers)
+ healthRtr.HandleFunc("", s.health)
+
// Home
- rtr.HandleFunc("/", s.home)
+ apiRtr.HandleFunc("/", s.home)
// SPIRE server healthcheck
- rtr.HandleFunc("/api/debugserver", s.debugServer)
- rtr.HandleFunc("/api/healthcheck", s.healthcheck)
+ apiRtr.HandleFunc("/api/debugserver", s.debugServer)
+ apiRtr.HandleFunc("/api/healthcheck", s.healthcheck)
// Agents
- rtr.HandleFunc("/api/agent/list", s.agentList)
- rtr.HandleFunc("/api/agent/ban", s.agentBan)
- rtr.HandleFunc("/api/agent/delete", s.agentDelete)
- rtr.HandleFunc("/api/agent/createjointoken", s.agentCreateJoinToken)
+ apiRtr.HandleFunc("/api/agent/list", s.agentList)
+ apiRtr.HandleFunc("/api/agent/ban", s.agentBan)
+ apiRtr.HandleFunc("/api/agent/delete", s.agentDelete)
+ apiRtr.HandleFunc("/api/agent/createjointoken", s.agentCreateJoinToken)
// Entries
- rtr.HandleFunc("/api/entry/list", s.entryList)
- rtr.HandleFunc("/api/entry/create", s.entryCreate)
- rtr.HandleFunc("/api/entry/delete", s.entryDelete)
+ apiRtr.HandleFunc("/api/entry/list", s.entryList)
+ apiRtr.HandleFunc("/api/entry/create", s.entryCreate)
+ apiRtr.HandleFunc("/api/entry/delete", s.entryDelete)
// Tornjak specific
- rtr.HandleFunc("/api/tornjak/serverinfo", s.tornjakGetServerInfo)
+ apiRtr.HandleFunc("/api/tornjak/serverinfo", s.tornjakGetServerInfo)
// Agents Selectors
- rtr.HandleFunc("/api/tornjak/selectors/register", s.tornjakPluginDefine)
- rtr.HandleFunc("/api/tornjak/selectors/list", s.tornjakSelectorsList)
- rtr.HandleFunc("/api/tornjak/agents/list", s.tornjakAgentsList)
+ apiRtr.HandleFunc("/api/tornjak/selectors/register", s.tornjakPluginDefine)
+ apiRtr.HandleFunc("/api/tornjak/selectors/list", s.tornjakSelectorsList)
+ apiRtr.HandleFunc("/api/tornjak/agents/list", s.tornjakAgentsList)
// Clusters
- rtr.HandleFunc("/api/tornjak/clusters/list", s.clusterList)
- rtr.HandleFunc("/api/tornjak/clusters/create", s.clusterCreate)
- rtr.HandleFunc("/api/tornjak/clusters/edit", s.clusterEdit)
- rtr.HandleFunc("/api/tornjak/clusters/delete", s.clusterDelete)
+ apiRtr.HandleFunc("/api/tornjak/clusters/list", s.clusterList)
+ apiRtr.HandleFunc("/api/tornjak/clusters/create", s.clusterCreate)
+ apiRtr.HandleFunc("/api/tornjak/clusters/edit", s.clusterEdit)
+ apiRtr.HandleFunc("/api/tornjak/clusters/delete", s.clusterDelete)
// Middleware
- rtr.Use(s.verificationMiddleware)
+ apiRtr.Use(s.verificationMiddleware)
// UI
spa := spaHandler{staticPath: "ui-agent", indexPath: "index.html"}
@@ -755,40 +780,78 @@ func NewAgentsDB(dbPlugin *ast.ObjectItem) (agentdb.AgentDB, error) {
}
}
-// NewAuth returns a new Auth
-func NewAuth(authPlugin *ast.ObjectItem) (auth.Auth, error) {
- key, data, _ := getPluginConfig(authPlugin)
- /*if err != nil { // default used, no error
- verifier := auth.NewNullVerifier()
- return verifier, nil
- }*/
+// NewAuthenticator returns a new Authenticator
+func NewAuthenticator(authenticatorPlugin *ast.ObjectItem) (authenticator.Authenticator, error) {
+ key, data, _ := getPluginConfig(authenticatorPlugin)
switch key {
- case "KeycloakAuth":
+ case "Keycloak":
// check if data is defined
if data == nil {
- return nil, errors.New("KeycloakAuth UserManagement plugin ('config > plugins > UserManagement KeycloakAuth > plugin_data') no populated")
+ return nil, errors.New("Keycloak Authenticator plugin ('config > plugins > Authenticator Keycloak > plugin_data') not populated")
}
- fmt.Printf("KeycloakAuth Usermanagement Data: %+v\n", data)
+ fmt.Printf("Authenticator Keycloak Plugin Data: %+v\n", data)
// decode config to struct
- var config pluginAuthKeycloak
+ var config pluginAuthenticatorKeycloak
if err := hcl.DecodeObject(&config, data); err != nil {
- return nil, errors.Errorf("Couldn't parse Auth config: %v", err)
+ return nil, errors.Errorf("Couldn't parse Authenticator config: %v", err)
}
// Log warning if audience is nil that aud claim is not checked
if config.Audience == "" {
- fmt.Printf("WARNING: Auth plugin has no expected audience configured - `aud` claim will not be checked (please populate 'config > plugins > UserManagement KeycloakAuth > plugin_data > audience')")
+ fmt.Println("WARNING: Auth plugin has no expected audience configured - `aud` claim will not be checked (please populate 'config > plugins > UserManagement KeycloakAuth > plugin_data > audience')")
+ }
+
+ // create authenticator TODO make json an option?
+ authenticator, err := authenticator.NewKeycloakAuthenticator(true, config.IssuerURL, config.Audience)
+ if err != nil {
+ return nil, errors.Errorf("Couldn't configure Authenticator: %v", err)
+ }
+ return authenticator, nil
+ default:
+ return nil, errors.Errorf("Invalid option for Authenticator named %s", key)
+ }
+}
+
+// NewAuthorizer returns a new Authorizer
+func NewAuthorizer(authorizerPlugin *ast.ObjectItem) (authorization.Authorizer, error) {
+ key, data, _ := getPluginConfig(authorizerPlugin)
+
+ switch key {
+ case "RBAC":
+ // check if data is defined
+ if data == nil {
+ return nil, errors.New("RBAC Authorizer plugin ('config > plugins > Authorizer RBAC > plugin_data') not populated")
}
+ fmt.Printf("Authorizer RBAC Plugin Data: %+v\n", data)
- // create verifier TODO make json an option?
- verifier, err := auth.NewKeycloakVerifier(true, config.IssuerURL, config.Audience)
+ // decode config to struct
+ var config pluginAuthorizerRBAC
+ if err := hcl.DecodeObject(&config, data); err != nil {
+ return nil, errors.Errorf("Couldn't parse Authorizer config: %v", err)
+ }
+
+ // decode into role list and apiMapping
+ roleList := make(map[string]string)
+ apiMapping := make(map[string][]string)
+ for _, role := range config.RoleList {
+ roleList[role.Name] = role.Desc
+ // print warning for empty string
+ if role.Name == "" {
+ fmt.Println("WARNING: using the empty string for an API enables access to all authenticated users")
+ }
+ }
+ for _, api := range config.APIRoleMappings {
+ apiMapping[api.Name] = api.AllowedRoles
+ }
+
+ authorizer, err := authorization.NewRBACAuthorizer(config.Name, roleList, apiMapping)
if err != nil {
- return nil, errors.Errorf("Couldn't configure Auth: %v", err)
+ return nil, errors.Errorf("Couldn't configure Authorizer: %v", err)
}
- return verifier, nil
+ return authorizer, nil
default:
- return nil, errors.Errorf("Invalid option for UserManagement named %s", key)
+ return nil, errors.Errorf("Invalid option for Authorizer named %s", key)
}
}
@@ -814,7 +877,8 @@ func (s *Server) VerifyConfiguration() error {
func (s *Server) ConfigureDefaults() error {
// no authorization is a default
- s.Auth = auth.NewNullVerifier()
+ s.Authenticator = authenticator.NewNullAuthenticator()
+ s.Authorizer = authorization.NewNullAuthorizer()
return nil
}
@@ -864,11 +928,17 @@ func (s *Server) Configure() error {
if err != nil {
return errors.Errorf("Cannot configure datastore plugin: %v", err)
}
- // configure auth
- case "UserManagement":
- s.Auth, err = NewAuth(pluginObject)
+ // configure Authenticator
+ case "Authenticator":
+ s.Authenticator, err = NewAuthenticator(pluginObject)
+ if err != nil {
+ return errors.Errorf("Cannot configure Authenticator plugin: %v", err)
+ }
+ // configure Authorizer
+ case "Authorizer":
+ s.Authorizer, err = NewAuthorizer(pluginObject)
if err != nil {
- return errors.Errorf("Cannot configure auth plugin: %v", err)
+ return errors.Errorf("Cannot configure Authorizer plugin: %v", err)
}
}
// TODO Handle when multiple plugins configured
diff --git a/api/agent/types.go b/api/agent/types.go
index 7a72cf6f..b5e39911 100644
--- a/api/agent/types.go
+++ b/api/agent/types.go
@@ -108,7 +108,23 @@ type pluginDataStoreSQL struct {
Filename string `hcl:"filename"`
}
-type pluginAuthKeycloak struct {
+type pluginAuthenticatorKeycloak struct {
IssuerURL string `hcl:"issuer"`
Audience string `hcl:"audience"`
}
+
+type AuthRole struct {
+ Name string `hcl:",key"`
+ Desc string `hcl:"desc"`
+}
+
+type APIRoleMapping struct {
+ Name string `hcl:",key"`
+ AllowedRoles []string `hcl:"allowed_roles"`
+}
+
+type pluginAuthorizerRBAC struct {
+ Name string `hcl:"name"`
+ RoleList []*AuthRole `hcl:"role,block"`
+ APIRoleMappings []*APIRoleMapping `hcl:"API,block"`
+}
diff --git a/docs/auth/keycloak/diagrams/browser-flow.png b/docs/auth/keycloak/diagrams/browser-flow.png
new file mode 100644
index 00000000..66a690f7
Binary files /dev/null and b/docs/auth/keycloak/diagrams/browser-flow.png differ
diff --git a/docs/auth/keycloak/diagrams/github-keycloak.png b/docs/auth/keycloak/diagrams/github-keycloak.png
new file mode 100644
index 00000000..56fd9c83
Binary files /dev/null and b/docs/auth/keycloak/diagrams/github-keycloak.png differ
diff --git a/docs/auth/keycloak/diagrams/github-oauth-app-secret.png b/docs/auth/keycloak/diagrams/github-oauth-app-secret.png
new file mode 100644
index 00000000..b68a3d45
Binary files /dev/null and b/docs/auth/keycloak/diagrams/github-oauth-app-secret.png differ
diff --git a/docs/auth/keycloak/diagrams/github-oauth-app.png b/docs/auth/keycloak/diagrams/github-oauth-app.png
new file mode 100644
index 00000000..7ef123cf
Binary files /dev/null and b/docs/auth/keycloak/diagrams/github-oauth-app.png differ
diff --git a/docs/auth/keycloak/diagrams/google-cloud-console.png b/docs/auth/keycloak/diagrams/google-cloud-console.png
new file mode 100644
index 00000000..72993789
Binary files /dev/null and b/docs/auth/keycloak/diagrams/google-cloud-console.png differ
diff --git a/docs/auth/keycloak/diagrams/google-cloud-credentials.png b/docs/auth/keycloak/diagrams/google-cloud-credentials.png
new file mode 100644
index 00000000..b4e728bc
Binary files /dev/null and b/docs/auth/keycloak/diagrams/google-cloud-credentials.png differ
diff --git a/docs/auth/keycloak/diagrams/google-keycloak.png b/docs/auth/keycloak/diagrams/google-keycloak.png
new file mode 100644
index 00000000..d9b6136b
Binary files /dev/null and b/docs/auth/keycloak/diagrams/google-keycloak.png differ
diff --git a/docs/auth/keycloak/diagrams/identity-providers-homepage.png b/docs/auth/keycloak/diagrams/identity-providers-homepage.png
new file mode 100644
index 00000000..bc89440e
Binary files /dev/null and b/docs/auth/keycloak/diagrams/identity-providers-homepage.png differ
diff --git a/docs/auth/keycloak/diagrams/keycloak-sign-in-page.png b/docs/auth/keycloak/diagrams/keycloak-sign-in-page.png
new file mode 100644
index 00000000..b2789acd
Binary files /dev/null and b/docs/auth/keycloak/diagrams/keycloak-sign-in-page.png differ
diff --git a/docs/auth/keycloak/diagrams/microsoft-azure-overview.png b/docs/auth/keycloak/diagrams/microsoft-azure-overview.png
new file mode 100644
index 00000000..6361f9c8
Binary files /dev/null and b/docs/auth/keycloak/diagrams/microsoft-azure-overview.png differ
diff --git a/docs/auth/keycloak/diagrams/microsoft-azure-secret.png b/docs/auth/keycloak/diagrams/microsoft-azure-secret.png
new file mode 100644
index 00000000..f8f04423
Binary files /dev/null and b/docs/auth/keycloak/diagrams/microsoft-azure-secret.png differ
diff --git a/docs/auth/keycloak/diagrams/microsoft-azure.png b/docs/auth/keycloak/diagrams/microsoft-azure.png
new file mode 100644
index 00000000..07742698
Binary files /dev/null and b/docs/auth/keycloak/diagrams/microsoft-azure.png differ
diff --git a/docs/auth/keycloak/diagrams/microsoft-keycloak.png b/docs/auth/keycloak/diagrams/microsoft-keycloak.png
new file mode 100644
index 00000000..2e72dafc
Binary files /dev/null and b/docs/auth/keycloak/diagrams/microsoft-keycloak.png differ
diff --git a/docs/auth/keycloak/diagrams/upstream-architecture-diagram.png b/docs/auth/keycloak/diagrams/upstream-architecture-diagram.png
new file mode 100644
index 00000000..9b445816
Binary files /dev/null and b/docs/auth/keycloak/diagrams/upstream-architecture-diagram.png differ
diff --git a/docs/keycloak-configuration.md b/docs/auth/keycloak/keycloak-configuration.md
similarity index 100%
rename from docs/keycloak-configuration.md
rename to docs/auth/keycloak/keycloak-configuration.md
diff --git a/docs/auth/keycloak/keycloak-upstream-IAMs-conf.md b/docs/auth/keycloak/keycloak-upstream-IAMs-conf.md
new file mode 100644
index 00000000..7ddfbdac
--- /dev/null
+++ b/docs/auth/keycloak/keycloak-upstream-IAMs-conf.md
@@ -0,0 +1,114 @@
+# Integrate Upstream Identity Providers to Keycloak
+
+Keycloak has the ability to integrate with upstream identity providers. This way admins can use existing IAMs and /or users database pools while securing Tornjak.
+
+![Upstream Architecture Diagram](diagrams/upstream-architecture-diagram.png)
+
+> [!NOTE]
+> As long as your upstream IAM uses `OAuth v2.0`, `OpenID Connect v1.0` or `SAML v2.0` you can integrate your IAM to keycloak.
+
+This documentation is a guide on how to connect some hand picked IAMs. For more IAM connection options, go to your keycloak console and head to the `Identity providers` section. There you can see multple options including:
+
+- Another Keycloak OpenID service
+- SAML
+- Google
+- Microsoft Azure - Active Directories (AD)
+- Openshift v3 and v4
+- Github
+- etc...
+
+![Identity Providers Homepage](diagrams/identity-providers-homepage.png)
+
+### Keycloak setup for Upstream IAMs
+
+#### -> Keycloak deployment and Tornjak connection
+
+To deploy keycloak and connect to Tornjak, you can use the documentation [here](https://github.com/spiffe/helm-charts-hardened/blob/main/examples/tornjak/keycloak/README.md).
+
+The keycloak setup to connect any upstream IAM is pretty much standard. Please follow the steps below to connect your upsteeam IAM of choice. Go to keycloak console and select the IAM of interest as shown in the picture above.
+
+> [!IMPORTANT]
+> Make sure you are in the `tornjak` realm or the specific realm Tornjak app is registered on
+- The `Redirect URI` is automatically set by keycloak, this is the uri you should enter in the `Redirect URI` section while creating/ updating your upstream IAM account (check below for specific upstream IAM instuctions).
+- Fill in the `Client ID` and `Client Secret` generated in the upstream IAM in the respective fields. [Go to your upstream IAM account to get the `Client ID` and `Client Secret`]
+- For `Microsoft Azure`single-tenant auth endpoints, fill in the `Tenant ID`. If not specified uses 'common' multi-tenant endpoints.
+- And click `Add`
+
+> [!IMPORTANT]
+> Make sure you assign appropraite roles within keycloak for your user, or the roles are mapped correctly between the upstream IAM and keycloak. Check the Mappers section for more roles configuration. [****DETAILED MAPPERS SECTION TODO!!!****]
+
+> Now when you try signing in to the Tornjak application, you should see the keycloak login page and the upstream IAM as an optional upstream identity provider to sign in to. If you select the upstream IAM, keycloak will redirect you to sign in with the upstream IAM of choice and tornjak will be authenticated using that IAM.
+
+### Microsoft
+![Microsoft Keycloak](diagrams/microsoft-keycloak.png)
+### Github
+![Github Keycloak](diagrams/github-keycloak.png)
+### Google
+![Google Keycloak](diagrams/google-keycloak.png)
+
+ Setup Microsoft Azure Active Directories (AD) as an Upstream Identity Provider
+If you don't have an AD account, follow the instructions below to create one.
+
+> [!NOTE]
+> For simplicity we will be creating a free account
+
+- Go to Microsoft Azure portal for your account.
+- Go to `App registrations` (you can search for it on top)
+- Click on `New Registration`
+- Configure the name, kepp the default `Single tenant`directory and add a `Redirect URI` by selecting `Web` and paste the value of `Redirect URI` from keycloak and Register the application.
+
+![Microsoft AD](diagrams/microsoft-azure.png)
+
+- Note the `Applcation (client) ID`, this is what you use to enter in the `Client ID` field while configuring the IAM on keycloak.
+- Note the `Directory (tenant) ID`, this is what you will use to enter in the `Tenant ID` field while configuring the IAM on keycloak.
+
+![Microsoft AD Overview](diagrams/microsoft-azure-overview.png)
+
+- On the left hand side go to Manage > Certificates & secrets and click on `New client secret`, give it a description and choose the expiry length ad click `Add` at the bottom. Note the `Value` of the created secret. This secret is what you will be using as the `Client Secret` to configure the IAM on keycloak.
+
+![Microsoft AD Secret](diagrams/microsoft-azure-secret.png)
+
+
+ Setup Github as an Upstream Identity Provider
+If you don't have a Github OAUTH app, follow the instructions below to create one.
+
+> [!NOTE]
+> For simplicity we will be creating an OAUTH app under a personal github account. But you can create one under an organization you have admin access to. Github uses OAuth 2.0.
+- In your personal github account, go to the upper-right corner of your github account and click on your profile photo and then click on settings.
+- At the bottom of the menu on the left hand side, select `<> Developer settings`
+- On the next page, select `OAuth Apps` on the left-hand side menu
+- Select `New OAuth App` or keep a note of your existing OAuth App if any.
+- Give your app a name in the `Application name` field such as `tornjak`, your application Homepage URL (http://localhost:3000) and the `Authorization callback URL` should be set to the `Redirect URI` assigned by keycloak.
+- And register your application.
+![Github OAuth](diagrams/github-oauth-app.png)
+- Once the application is registered click on `Generate a new client secret` and keep a note of the `Client ID` and the `Client Secret` generated. This is what you will use to configure upstream IAM on keycloak.
+
+![Github OAuth Secret](diagrams/github-oauth-app-secret.png)
+
+ Setup Google as an Upstream Identity Provider
+If you don't have a Google account, follow the instrctions below to create one.
+
+> [!NOTE]
+> For simplicity we will be creating an OAUTH app under a personal google account.
+- Go to Google Cloud Platform console: https://console.cloud.google.com
+![Google Cloud Console](diagrams/google-cloud-console.png)
+- Go to `APIs & Services` -> `OAuth consent screen` tab on left hand side and select `External` user type consent screen to create a new consent screen.
+- After creating the consent screen, go to `Credentials` tab on left hand side, click on `CREATE CREDENTIALS` and select `OAuth Client ID`. For `Application type` select `Web application`. Give it a client name, for `Authorized redirect URIs` use the Redirect URI in your keycloak console.
+- Once you click create note your `Client id` and `Client secret`. This is what you will use to configure upstream IAM on keycloak.
+![Google Cloud Credentials](diagrams/google-cloud-credentials.png)
+
+ Setup Openshift as an Upstream Identity Provider
+If you don't have an Openshift account, follow the instrctions below to create one.
+
+****DOCUMENTATION FOR OPENSHIFT TO BE ADDED!!!****
+
+
+- As you add an upstream IAM, you will see an option to sign in with the IAM in the keycloak login page as shown on the picture below.
+![Keycloak Sign In](diagrams/keycloak-sign-in-page.png)
+
+> [!TIP]
+> To make an upstream identity provider default: in another words for keycloak to directly redirect to the choosen identity provider, instead of showing the default keycloak login page follow the following steps below:
+- Click on `Authentication` on the left handside menu
+- Choose the `browser` flow
+![Keycloak Browser Flow](diagrams/browser-flow.png)
+- Click on the gear icon on the `Identity Provider Redirector` and set to the alias of the identity provider you want keycloak to redirect to.
\ No newline at end of file
diff --git a/docs/conf/agent/full.conf b/docs/conf/agent/full.conf
index e351ff69..e1814daa 100644
--- a/docs/conf/agent/full.conf
+++ b/docs/conf/agent/full.conf
@@ -41,8 +41,9 @@ plugins {
### BEGIN IAM PLUGIN CONFIGURATION ###
# Note: if no UserManagement configuration included, authentication treated as noop
- # Configure Keycloak as external Authentication server
- UserManagement "KeycloakAuth" {
+ # This plugin will extract roles from `realm_access.roles` in the JWT and pass
+ # to the authorization layer as user roles.
+ Authenticator "Keycloak" {
plugin_data {
# issuer - Issuer URL for OIDC
# here is a sample for Keycloak running locally on Minikube
@@ -57,6 +58,40 @@ plugins {
}
}
+ # This policy requires admin role for all write calls, viewer role for all read calls
+ # and authentication success for the "/" api
+ Authorizer "RBAC" {
+ plugin_data {
+ name = "Admin Viewer Policy"
+ role "admin" { desc = "admin person" }
+ role "viewer" { desc = "viewer person" }
+ # this special character role is reserved for allowing all authenticated persons
+ role "" { desc = "authenticated person" }
+
+ # home tornjak backend api allowed with any successful authentication
+ API "/" { allowed_roles = [""] }
+ # allowed with successful authentication and either admin or viewer role
+ API "/api/healthcheck" { allowed_roles = ["admin", "viewer"] }
+ API "/api/debugserver" { allowed_roles = ["admin", "viewer"] }
+ API "/api/agent/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/entry/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/serverinfo" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/selectors/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/agents/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/clusters/list" { allowed_roles = ["admin", "viewer"] }
+ # allowed with successful authentication and admin role
+ API "/api/agent/ban" { allowed_roles = ["admin"] }
+ API "/api/agent/delete" { allowed_roles = ["admin"] }
+ API "/api/agent/createjointoken" { allowed_roles = ["admin"] }
+ API "/api/entry/create" { allowed_roles = ["admin"] }
+ API "/api/entry/delete" { allowed_roles = ["admin"] }
+ API "/api/tornjak/selectors/register" { allowed_roles = ["admin"] }
+ API "/api/tornjak/clusters/create" { allowed_roles = ["admin"] }
+ API "/api/tornjak/clusters/edit" { allowed_roles = ["admin"] }
+ API "/api/tornjak/clusters/delete" { allowed_roles = ["admin"] }
+ }
+ }
+
### END IAM PLUGIN CONFIGURATION
diff --git a/docs/config-tornjak-server.md b/docs/config-tornjak-server.md
index 135f87c6..0ff338c3 100644
--- a/docs/config-tornjak-server.md
+++ b/docs/config-tornjak-server.md
@@ -63,19 +63,25 @@ For examples on enabling TLS and mTLS connections, please see [our TLS and mTLS
## About Tornjak plugins
+Tornjak supports several different plugin types, each representing a different functionality. The diagram below shows how each of the plugin types fit into the backend:
+
+![tornjak backend plugin diagram](./rsrc/tornjak-backend-plugin-diagram.png)
+
### Plugin types
-| Type | Description | Required |
-|:---------------|:------------|:---------|
-| DataStore | Provides persistent storage for Tornjak metadata. | True |
-| UserManagement | Secures access to Tornjak agent and enables authorization logic | False |
+| Type | Description | Required |
+|:--------------|:------------|:---------|
+| DataStore | Provides persistent storage for Tornjak metadata. | True |
+| Authenticator | Verify tokens signed by external OIDC server and extract user information to be passed to the Authorization layer. Any user information or errors from this layer are to be interpreted by an Authorizer layer. | False |
+| Authorizer | Based on user information or errors passed from authentication layer and API call details, apply authorization logic. | False |
### Built-in plugins
| Type | Name | Description |
| ---- | ---- | ----------- |
-| DataStore | [sql]() | Default SQL storage for Tornjak metadata |
-| UserManagement | [keycloak](/docs/plugin_server_auth_keycloak.md) | Requires JWT Bearer Access Token provided for each request. More details in [our auth feature doc](/docs/user-management.md) |
+| DataStore | [sql]() | Default SQL storage for Tornjak metadata |
+| Authenticator | [keycloak](/docs/plugin_server_authentication_keycloak.md) | Perform OIDC Discovery and extract roles from `realmAccess.roles` field |
+| Authorizer | [RBAC](/docs/plugin_server_authorization_rbac.md) | Check api permission based on user role and defined authorization logic |
### Plugin configuration
diff --git a/docs/plugin_server_auth_keycloak.md b/docs/plugin_server_authentication_keycloak.md
similarity index 58%
rename from docs/plugin_server_auth_keycloak.md
rename to docs/plugin_server_authentication_keycloak.md
index 799bfc6b..c81900db 100644
--- a/docs/plugin_server_auth_keycloak.md
+++ b/docs/plugin_server_authentication_keycloak.md
@@ -1,8 +1,8 @@
-# Server plugin: Authorization "keycloak"
+# Server plugin: Authentication "Keycloak"
Please see our documentation on the [authorization feature](./user-management.md) for more complete details.
-Note that configuring this requires the frontend to be configured to obtain access tokens at the relevant auth server.
+Note that simply enabling this feature will NOT enable authorization. In order to apply authorization logic to user details, one must also enable an Authorization plugin. Any output from this layer, including authentication errors, are to be interpreted by an Authorization layer.
The configuration has the following key-value pairs:
@@ -14,13 +14,19 @@ The configuration has the following key-value pairs:
A sample configuration file for syntactic referense is below:
```hcl
- UserManagement "KeycloakAuth" {
+ Authenticator "Keycloak" {
plugin_data {
- issuer = "http://localhost:8080/realms/tornjak"
+ issuer = "http://host.docker.internal:8080/realms/tornjak"
audience = "tornjak-backend"
}
}
```
-NOTE: If audience field is missing or empty, the server will log an error and NOT perform an audience check.
+NOTE: If audience field is missing or empty, the server will log a warning and NOT perform an audience check.
It is highly recommended `audience` is populated to ensure only tokens meant for the Tornjak Backend are accepted.
+
+## User Info extracted
+
+This plugin assumes roles are available in `realm_access.roles` in the JWT and passes this list as user.roles.
+
+These mapped values are passed to the authorization layer.
diff --git a/docs/plugin_server_authorization_rbac.md b/docs/plugin_server_authorization_rbac.md
new file mode 100644
index 00000000..781e7ef9
--- /dev/null
+++ b/docs/plugin_server_authorization_rbac.md
@@ -0,0 +1,70 @@
+# Server plugin: Authorization "RBAC"
+
+Please see our documentation on the [authorization feature](./user-management.md) for more complete details.
+
+This configuration has the following inputs:
+
+| Key | Description | Required |
+| --- | ----------- | -------- |
+| name | name of the policy for logging purposes | no |
+| `role "" {desc = ""}` | `` is the name of a role that can be allowed access; `` is a short description | no |
+| `API "" {allowed_roles = ["", ...]}` | `` is the name of the API that will allow access to roles listed such as `` | no |
+
+There can (and likely will be) multiple `role` and `API` blocks. If there are no role blocks, no API will be allowed any access. If there is a missing API block, no access will be granted for that API.
+
+A sample configuration file for syntactic referense is below:
+
+```hcl
+Authorizer "RBAC" {
+ plugin_data {
+ name = "Admin Viewer Policy"
+ role "admin" { desc = "admin person" }
+ role "viewer" { desc = "viewer person" }
+ role "" { desc = "authenticated person" }
+
+ API "/" { allowed_roles = [""] }
+ API "/api/healthcheck" { allowed_roles = ["admin", "viewer"] }
+ API "/api/debugserver" { allowed_roles = ["admin", "viewer"] }
+ API "/api/agent/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/entry/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/serverinfo" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/selectors/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/agents/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/tornjak/clusters/list" { allowed_roles = ["admin", "viewer"] }
+ API "/api/agent/ban" { allowed_roles = ["admin"] }
+ API "/api/agent/delete" { allowed_roles = ["admin"] }
+ API "/api/agent/createjointoken" { allowed_roles = ["admin"] }
+ API "/api/entry/create" { allowed_roles = ["admin"] }
+ API "/api/entry/delete" { allowed_roles = ["admin"] }
+ API "/api/tornjak/selectors/register" { allowed_roles = ["admin"] }
+ API "/api/tornjak/clusters/create" { allowed_roles = ["admin"] }
+ API "/api/tornjak/clusters/edit" { allowed_roles = ["admin"] }
+ API "/api/tornjak/clusters/delete" { allowed_roles = ["admin"] }
+ }
+}
+```
+
+NOTE: If this feature is enabled without an authentication layer, it will render all calls uncallable.
+
+The above specification assumes roles `admin` and `viewer` are passed by the authentication layer. In this example, the following apply:
+
+1. If user has `admin` role, can perform any call
+2. If user has `viewer` role, can perform all read-only calls (See lists below)
+3. If user is authenticated with no role, can perform only `/` Tornjak home call.
+
+## Valid inputs
+
+There are a couple failure cases in which the plugin will fail to initialize and the Tornjak backend will not run:
+
+1. If an included API block has an undefined API (`API "" {...}` where `x` is not a Tornjak API)
+2. If an included API block has an undefined role (There exists `API "" {allowed_roles = [..., "", ...]}` such that for all `role "" {...}`, `y != z`)
+
+## The empty string role ""
+
+If there is a role listed with name `""`, this enables some APIs to allow all users where the authentication layer does not return error. In the above example, only the `/` API has this behavior.
+
+## Additional behavior specification
+
+If there is a role that is not included as an `allowed_role` in any API block, a user will not be granted access to any API based on that role.
+
+
diff --git a/docs/plugin_server_datastore_sql.md b/docs/plugin_server_datastore_sql.md
new file mode 100644
index 00000000..c4279fbb
--- /dev/null
+++ b/docs/plugin_server_datastore_sql.md
@@ -0,0 +1,22 @@
+# Server plugin: Datastore "SQL"
+
+Note the Datastore is a required plugin, and currently, as the SQL datastore is the only supported instance of the datastore plugin, there must be a section configuring this upon Tornjak backend startup.
+
+The configuration has the following key-value pairs:
+
+| Key | Description | Required |
+| ----------- | ---------------------------- | ------------------- |
+| drivername | Driver for SQL database | True |
+| filename | Location of database | True |
+
+A sample configuration file for syntactic reference is below:
+
+```hcl
+ DataStore "sql" {
+ plugin_data {
+ issuer = "sqlite3"
+ audience = "/run/spire/data/tornjak.sqlite3"
+ }
+ }
+```
+
diff --git a/docs/rsrc/tornjak-backend-plugin-diagram.png b/docs/rsrc/tornjak-backend-plugin-diagram.png
new file mode 100644
index 00000000..aa69be80
Binary files /dev/null and b/docs/rsrc/tornjak-backend-plugin-diagram.png differ
diff --git a/docs/user-management.md b/docs/user-management.md
index b2b0c85b..b6b88be5 100644
--- a/docs/user-management.md
+++ b/docs/user-management.md
@@ -46,13 +46,15 @@ with more details on the general configuration
[here](/docs/config-tornjak-server.md). Most notably, populate a new plugin section for keycloak as defined [here](/docs/plugin_server_auth_keycloak.md) like so:
```
...
- UserManagement "KeycloakAuth" {
+ Authenticator "Keycloak" {
plugin_data {
# issuer - Issuer URL for OIDC
issuer = "http://host.docker.internal:8080/realms/tornjak"
audience = "tornjak-backend"
}
}
+
+ Authorizer "AdminViewer" {}
...
```
diff --git a/examples/keycloak/README.md b/examples/keycloak/README.md
index 0543532e..c0dfe2be 100644
--- a/examples/keycloak/README.md
+++ b/examples/keycloak/README.md
@@ -21,4 +21,4 @@ and open the *Administration Console*
The credentials in this example have username and password both `admin`. You may configure this in `statefulset.yaml`
-The Tornjak Realm has two users: `admin` and `viewer`.
+The Tornjak Realm has two users with usernames: `admin` and `viewer`, and passwords `admin` and `viewer` respectively.
diff --git a/examples/keycloak/config.yaml b/examples/keycloak/config.yaml
index 31f13861..417eeaa6 100644
--- a/examples/keycloak/config.yaml
+++ b/examples/keycloak/config.yaml
@@ -51,24 +51,6 @@ data:
"failureFactor": 30,
"roles": {
"realm": [
- {
- "id": "f7447da4-8316-41b3-a424-cc39f850e216",
- "name": "tornjak-viewer-realm-role",
- "description": "",
- "composite": false,
- "clientRole": false,
- "containerId": "f328c058-824c-4f64-bf73-6b45b0fbc914",
- "attributes": {}
- },
- {
- "id": "ef18c2b2-cd99-465d-9d06-d717365b070c",
- "name": "tornjak-admin-realm-role",
- "description": "",
- "composite": false,
- "clientRole": false,
- "containerId": "f328c058-824c-4f64-bf73-6b45b0fbc914",
- "attributes": {}
- },
{
"id": "9e2d7e27-e931-45a8-a44f-05175500005e",
"name": "uma_authorization",
@@ -99,6 +81,24 @@ data:
"containerId": "f328c058-824c-4f64-bf73-6b45b0fbc914",
"attributes": {}
},
+ {
+ "id": "2593f3ba-6607-4fce-84b9-411b427330be",
+ "name": "viewer",
+ "description": "",
+ "composite": false,
+ "clientRole": false,
+ "containerId": "f328c058-824c-4f64-bf73-6b45b0fbc914",
+ "attributes": {}
+ },
+ {
+ "id": "be442260-4450-448c-be76-c11b3d0fd483",
+ "name": "admin",
+ "description": "",
+ "composite": false,
+ "clientRole": false,
+ "containerId": "f328c058-824c-4f64-bf73-6b45b0fbc914",
+ "attributes": {}
+ },
{
"id": "e31fd018-82ff-48fb-bd02-5a440fb3e5e4",
"name": "offline_access",
@@ -340,12 +340,7 @@ data:
{
"id": "65a0126a-1a3e-4af0-ac2c-6c7926d4cc91",
"name": "viewer",
- "composite": true,
- "composites": {
- "realm": [
- "tornjak-viewer-realm-role"
- ]
- },
+ "composite": false,
"clientRole": true,
"containerId": "5879eb39-2121-4fa6-93cf-6e91392e0508",
"attributes": {}
@@ -353,12 +348,7 @@ data:
{
"id": "78df9ca6-7976-4dd1-b2c5-2e09c1d8bc45",
"name": "admin",
- "composite": true,
- "composites": {
- "realm": [
- "tornjak-admin-realm-role"
- ]
- },
+ "composite": false,
"clientRole": true,
"containerId": "5879eb39-2121-4fa6-93cf-6e91392e0508",
"attributes": {}
@@ -452,7 +442,7 @@ data:
"path": "/admin",
"attributes": {},
"realmRoles": [
- "tornjak-admin-realm-role"
+ "admin"
],
"clientRoles": {},
"subGroups": []
@@ -463,7 +453,7 @@ data:
"path": "/viewer",
"attributes": {},
"realmRoles": [
- "tornjak-viewer-realm-role"
+ "viewer"
],
"clientRoles": {},
"subGroups": []
@@ -627,7 +617,8 @@ data:
"config": {
"id.token.claim": "false",
"access.token.claim": "true",
- "included.custom.audience": "tornjak-backend"
+ "included.custom.audience": "tornjak-backend",
+ "userinfo.token.claim": "false"
}
}
],
@@ -1482,14 +1473,14 @@ data:
"subComponents": {},
"config": {
"allowed-protocol-mapper-types": [
- "saml-user-property-mapper",
"oidc-address-mapper",
"oidc-usermodel-attribute-mapper",
- "oidc-full-name-mapper",
+ "saml-user-attribute-mapper",
"oidc-sha256-pairwise-sub-mapper",
+ "oidc-full-name-mapper",
"oidc-usermodel-property-mapper",
"saml-role-list-mapper",
- "saml-user-attribute-mapper"
+ "saml-user-property-mapper"
]
}
},
@@ -1502,13 +1493,13 @@ data:
"config": {
"allowed-protocol-mapper-types": [
"oidc-sha256-pairwise-sub-mapper",
- "saml-user-attribute-mapper",
- "oidc-full-name-mapper",
"saml-role-list-mapper",
- "saml-user-property-mapper",
+ "oidc-full-name-mapper",
"oidc-usermodel-property-mapper",
"oidc-usermodel-attribute-mapper",
- "oidc-address-mapper"
+ "oidc-address-mapper",
+ "saml-user-attribute-mapper",
+ "saml-user-property-mapper"
]
}
},
@@ -1637,7 +1628,7 @@ data:
"supportedLocales": [],
"authenticationFlows": [
{
- "id": "4088582a-11d7-4f3f-a8e8-55d32a1c76b3",
+ "id": "a0d2ae19-1a29-4bf4-ae40-c4876fc9c691",
"alias": "Account verification options",
"description": "Method with which to verity the existing account",
"providerId": "basic-flow",
@@ -1663,7 +1654,7 @@ data:
]
},
{
- "id": "9ea5725d-b25b-4e91-969f-b8f3ac55a9ea",
+ "id": "1cadc6d6-e038-4728-b2d7-606d994ad926",
"alias": "Authentication Options",
"description": "Authentication options.",
"providerId": "basic-flow",
@@ -1697,7 +1688,7 @@ data:
]
},
{
- "id": "60526891-c368-4a1d-a575-c0b58ba36cbe",
+ "id": "2944a1f5-c2d0-4878-a403-5d9b84cf9cf5",
"alias": "Browser - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow",
@@ -1723,7 +1714,7 @@ data:
]
},
{
- "id": "6a763267-a45d-453a-be79-f26e063dab09",
+ "id": "70342ff7-5c14-44df-ba0f-f1f7a3bf5d6e",
"alias": "Direct Grant - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow",
@@ -1749,7 +1740,7 @@ data:
]
},
{
- "id": "6dc06302-aeda-49cb-98c5-15d8d2ed9024",
+ "id": "a0a4f27b-0f47-4341-8e48-96e98152063c",
"alias": "First broker login - Conditional OTP",
"description": "Flow to determine if the OTP is required for the authentication",
"providerId": "basic-flow",
@@ -1775,7 +1766,7 @@ data:
]
},
{
- "id": "f9b3f24c-4039-4165-9822-be6665ea4647",
+ "id": "d6af0bbb-e085-4ef8-a0fa-775bc6a20391",
"alias": "Handle Existing Account",
"description": "Handle what to do if there is existing account with same email/username like authenticated identity provider",
"providerId": "basic-flow",
@@ -1801,7 +1792,7 @@ data:
]
},
{
- "id": "085ea431-fead-4581-92fb-408cbe566a09",
+ "id": "17f8b8d5-ea2e-48e8-96d3-8f860072dd7b",
"alias": "Reset - Conditional OTP",
"description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.",
"providerId": "basic-flow",
@@ -1827,7 +1818,7 @@ data:
]
},
{
- "id": "c468f235-6276-40cb-8cbf-1649890c4d6e",
+ "id": "9ccee1f8-ca00-41ee-8f33-846a881a17d9",
"alias": "User creation or linking",
"description": "Flow for the existing/non-existing user alternatives",
"providerId": "basic-flow",
@@ -1854,7 +1845,7 @@ data:
]
},
{
- "id": "d591d312-f782-4bd1-8ad1-aa940a3d9f37",
+ "id": "66b2e00f-520b-416f-9c22-68e737447cc2",
"alias": "Verify Existing Account by Re-authentication",
"description": "Reauthentication of existing account",
"providerId": "basic-flow",
@@ -1880,7 +1871,7 @@ data:
]
},
{
- "id": "e3229dcb-b8cc-4d85-a493-665bf70c1bb9",
+ "id": "86d97d48-a1b0-4e5e-a523-95d72254707e",
"alias": "browser",
"description": "browser based authentication",
"providerId": "basic-flow",
@@ -1922,7 +1913,7 @@ data:
]
},
{
- "id": "192229a4-987b-4fd4-ab9b-8af694cbcbff",
+ "id": "dc57eaf8-1355-44ac-9320-d7b33075920f",
"alias": "clients",
"description": "Base authentication for clients",
"providerId": "client-flow",
@@ -1964,7 +1955,7 @@ data:
]
},
{
- "id": "95031e63-79ba-4592-9b34-adc3fe48c49b",
+ "id": "9bd62cd6-001b-4b89-80b0-0f738d1b6ece",
"alias": "direct grant",
"description": "OpenID Connect Resource Owner Grant",
"providerId": "basic-flow",
@@ -1998,7 +1989,7 @@ data:
]
},
{
- "id": "bb6c332b-3b65-4a07-a9b2-96483504bccd",
+ "id": "d7d1fa18-14e8-4a24-a869-5a2491708c8d",
"alias": "docker auth",
"description": "Used by Docker clients to authenticate against the IDP",
"providerId": "basic-flow",
@@ -2016,7 +2007,7 @@ data:
]
},
{
- "id": "5d3ccb15-7b2a-4396-a71a-381c523ce344",
+ "id": "ece951a2-3897-420b-b017-515e6b50fb7d",
"alias": "first broker login",
"description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account",
"providerId": "basic-flow",
@@ -2043,7 +2034,7 @@ data:
]
},
{
- "id": "fcfc4df2-a50b-4094-8b91-81ef05a6d446",
+ "id": "1cbe0eca-7666-4525-99c8-b55e8b2c613e",
"alias": "forms",
"description": "Username, password, otp and other auth forms.",
"providerId": "basic-flow",
@@ -2069,7 +2060,7 @@ data:
]
},
{
- "id": "e5eeb267-7d6e-4232-b687-da1dbbbe6fbd",
+ "id": "6a48989a-c1ba-4146-8234-61c923dfed93",
"alias": "http challenge",
"description": "An authentication flow based on challenge-response HTTP Authentication Schemes",
"providerId": "basic-flow",
@@ -2095,7 +2086,7 @@ data:
]
},
{
- "id": "b01af0c8-30e7-463c-81d0-fccbfb6cbe78",
+ "id": "68f379a9-00b4-4981-977f-3e1714f1b925",
"alias": "registration",
"description": "registration flow",
"providerId": "basic-flow",
@@ -2114,7 +2105,7 @@ data:
]
},
{
- "id": "e405584c-04cb-41ae-a1e6-09742bd06175",
+ "id": "45835836-efe7-41c5-98ef-ea4d7ff6ea95",
"alias": "registration form",
"description": "registration form",
"providerId": "form-flow",
@@ -2156,7 +2147,7 @@ data:
]
},
{
- "id": "92174ab4-8f40-4d0c-b47f-578545e65d72",
+ "id": "4c8fdfeb-b20e-4d34-8f53-5f9a9fafcba2",
"alias": "reset credentials",
"description": "Reset credentials for a user if they forgot their password or something",
"providerId": "basic-flow",
@@ -2198,7 +2189,7 @@ data:
]
},
{
- "id": "22fdad5c-a195-423a-a95c-67af0ddf7874",
+ "id": "ca564ca1-2253-44f3-bad0-15efeb995f6b",
"alias": "saml ecp",
"description": "SAML ECP Profile Authentication Flow",
"providerId": "basic-flow",
@@ -2218,14 +2209,14 @@ data:
],
"authenticatorConfig": [
{
- "id": "bee29af0-0b4b-402d-a514-ebf57aa8701d",
+ "id": "5cabfdc6-2cc7-453f-bf83-83d29c1ffba2",
"alias": "create unique user config",
"config": {
"require.password.update.after.registration": "false"
}
},
{
- "id": "22d06d43-b441-44ba-b3d5-fe96e3afd5bc",
+ "id": "de70db4a-e3ab-4b8a-84af-02501f5974b4",
"alias": "review profile config",
"config": {
"update.profile.on.first.login": "missing"
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d2cae771..65b68ef7 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -33,7 +33,7 @@
"jwt-decode": "^3.1.2",
"keycloak-js": "^19.0.1",
"moment": "^2.29.4",
- "next": "^13.4.6",
+ "next": "^14.1.1",
"prop-types": "^15.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -3984,14 +3984,14 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/@next/env": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-13.5.6.tgz",
- "integrity": "sha512-Yac/bV5sBGkkEXmAX5FWPS9Mmo2rthrOPRQQNfycJPkjUAUclomCPH7QFVCDQ4Mp2k2K1SSM6m0zrxYrOwtFQw=="
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.1.tgz",
+ "integrity": "sha512-7CnQyD5G8shHxQIIg3c7/pSeYFeMhsNbpU/bmvH7ZnDql7mNRgg8O2JZrhrc/soFnfBnKP4/xXNiiSIPn2w8gA=="
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.6.tgz",
- "integrity": "sha512-5nvXMzKtZfvcu4BhtV0KH1oGv4XEW+B+jOfmBdpFI3C7FrB/MfujRpWYSBBO64+qbW8pkZiSyQv9eiwnn5VIQA==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.1.tgz",
+ "integrity": "sha512-yDjSFKQKTIjyT7cFv+DqQfW5jsD+tVxXTckSe1KIouKk75t1qZmj/mV3wzdmFb0XHVGtyRjDMulfVG8uCKemOQ==",
"cpu": [
"arm64"
],
@@ -4004,9 +4004,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.6.tgz",
- "integrity": "sha512-6cgBfxg98oOCSr4BckWjLLgiVwlL3vlLj8hXg2b+nDgm4bC/qVXXLfpLB9FHdoDu4057hzywbxKvmYGmi7yUzA==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.1.tgz",
+ "integrity": "sha512-KCQmBL0CmFmN8D64FHIZVD9I4ugQsDBBEJKiblXGgwn7wBCSe8N4Dx47sdzl4JAg39IkSN5NNrr8AniXLMb3aw==",
"cpu": [
"x64"
],
@@ -4019,9 +4019,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.6.tgz",
- "integrity": "sha512-txagBbj1e1w47YQjcKgSU4rRVQ7uF29YpnlHV5xuVUsgCUf2FmyfJ3CPjZUvpIeXCJAoMCFAoGnbtX86BK7+sg==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.1.tgz",
+ "integrity": "sha512-YDQfbWyW0JMKhJf/T4eyFr4b3tceTorQ5w2n7I0mNVTFOvu6CGEzfwT3RSAQGTi/FFMTFcuspPec/7dFHuP7Eg==",
"cpu": [
"arm64"
],
@@ -4034,9 +4034,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.6.tgz",
- "integrity": "sha512-cGd+H8amifT86ZldVJtAKDxUqeFyLWW+v2NlBULnLAdWsiuuN8TuhVBt8ZNpCqcAuoruoSWynvMWixTFcroq+Q==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.1.tgz",
+ "integrity": "sha512-fiuN/OG6sNGRN/bRFxRvV5LyzLB8gaL8cbDH5o3mEiVwfcMzyE5T//ilMmaTrnA8HLMS6hoz4cHOu6Qcp9vxgQ==",
"cpu": [
"arm64"
],
@@ -4049,9 +4049,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.6.tgz",
- "integrity": "sha512-Mc2b4xiIWKXIhBy2NBTwOxGD3nHLmq4keFk+d4/WL5fMsB8XdJRdtUlL87SqVCTSaf1BRuQQf1HvXZcy+rq3Nw==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.1.tgz",
+ "integrity": "sha512-rv6AAdEXoezjbdfp3ouMuVqeLjE1Bin0AuE6qxE6V9g3Giz5/R3xpocHoAi7CufRR+lnkuUjRBn05SYJ83oKNQ==",
"cpu": [
"x64"
],
@@ -4064,9 +4064,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.6.tgz",
- "integrity": "sha512-CFHvP9Qz98NruJiUnCe61O6GveKKHpJLloXbDSWRhqhkJdZD2zU5hG+gtVJR//tyW897izuHpM6Gtf6+sNgJPQ==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.1.tgz",
+ "integrity": "sha512-YAZLGsaNeChSrpz/G7MxO3TIBLaMN8QWMr3X8bt6rCvKovwU7GqQlDu99WdvF33kI8ZahvcdbFsy4jAFzFX7og==",
"cpu": [
"x64"
],
@@ -4079,9 +4079,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.6.tgz",
- "integrity": "sha512-aFv1ejfkbS7PUa1qVPwzDHjQWQtknzAZWGTKYIAaS4NMtBlk3VyA6AYn593pqNanlicewqyl2jUhQAaFV/qXsg==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.1.tgz",
+ "integrity": "sha512-1L4mUYPBMvVDMZg1inUYyPvFSduot0g73hgfD9CODgbr4xiTYe0VOMTZzaRqYJYBA9mana0x4eaAaypmWo1r5A==",
"cpu": [
"arm64"
],
@@ -4094,9 +4094,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.6.tgz",
- "integrity": "sha512-XqqpHgEIlBHvzwG8sp/JXMFkLAfGLqkbVsyN+/Ih1mR8INb6YCc2x/Mbwi6hsAgUnqQztz8cvEbHJUbSl7RHDg==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.1.tgz",
+ "integrity": "sha512-jvIE9tsuj9vpbbXlR5YxrghRfMuG0Qm/nZ/1KDHc+y6FpnZ/apsgh+G6t15vefU0zp3WSpTMIdXRUsNl/7RSuw==",
"cpu": [
"ia32"
],
@@ -4109,9 +4109,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.6.tgz",
- "integrity": "sha512-Cqfe1YmOS7k+5mGu92nl5ULkzpKuxJrP3+4AEuPmrpFZ3BHxTY3TnHmU1On3bFmFFs6FbTcdF58CCUProGpIGQ==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.1.tgz",
+ "integrity": "sha512-S6K6EHDU5+1KrBDLko7/c1MNy/Ya73pIAmvKeFwsF4RmBFJSO7/7YeD4FnZ4iBdzE69PpQ4sOMU9ORKeNuxe8A==",
"cpu": [
"x64"
],
@@ -7065,9 +7065,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001577",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001577.tgz",
- "integrity": "sha512-rs2ZygrG1PNXMfmncM0B5H1hndY5ZCC9b5TkFaVNfZ+AUlyqcMyVIQtc3fsezi0NUCk5XZfDf9WS6WxMxnfdrg==",
+ "version": "1.0.30001617",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz",
+ "integrity": "sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA==",
"funding": [
{
"type": "opencollective",
@@ -9143,9 +9143,9 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/ejs": {
- "version": "3.1.9",
- "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz",
- "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==",
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"dependencies": {
"jake": "^10.8.5"
},
@@ -15350,34 +15350,34 @@
}
},
"node_modules/next": {
- "version": "13.5.6",
- "resolved": "https://registry.npmjs.org/next/-/next-13.5.6.tgz",
- "integrity": "sha512-Y2wTcTbO4WwEsVb4A8VSnOsG1I9ok+h74q0ZdxkwM3EODqrs4pasq7O0iUxbcS9VtWMicG7f3+HAj0r1+NtKSw==",
+ "version": "14.1.1",
+ "resolved": "https://registry.npmjs.org/next/-/next-14.1.1.tgz",
+ "integrity": "sha512-McrGJqlGSHeaz2yTRPkEucxQKe5Zq7uPwyeHNmJaZNY4wx9E9QdxmTp310agFRoMuIYgQrCrT3petg13fSVOww==",
"dependencies": {
- "@next/env": "13.5.6",
+ "@next/env": "14.1.1",
"@swc/helpers": "0.5.2",
"busboy": "1.6.0",
- "caniuse-lite": "^1.0.30001406",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
"postcss": "8.4.31",
- "styled-jsx": "5.1.1",
- "watchpack": "2.4.0"
+ "styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
- "node": ">=16.14.0"
+ "node": ">=18.17.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "13.5.6",
- "@next/swc-darwin-x64": "13.5.6",
- "@next/swc-linux-arm64-gnu": "13.5.6",
- "@next/swc-linux-arm64-musl": "13.5.6",
- "@next/swc-linux-x64-gnu": "13.5.6",
- "@next/swc-linux-x64-musl": "13.5.6",
- "@next/swc-win32-arm64-msvc": "13.5.6",
- "@next/swc-win32-ia32-msvc": "13.5.6",
- "@next/swc-win32-x64-msvc": "13.5.6"
+ "@next/swc-darwin-arm64": "14.1.1",
+ "@next/swc-darwin-x64": "14.1.1",
+ "@next/swc-linux-arm64-gnu": "14.1.1",
+ "@next/swc-linux-arm64-musl": "14.1.1",
+ "@next/swc-linux-x64-gnu": "14.1.1",
+ "@next/swc-linux-x64-musl": "14.1.1",
+ "@next/swc-win32-arm64-msvc": "14.1.1",
+ "@next/swc-win32-ia32-msvc": "14.1.1",
+ "@next/swc-win32-x64-msvc": "14.1.1"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
diff --git a/frontend/package.json b/frontend/package.json
index 15880023..04938e82 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,7 +28,7 @@
"jwt-decode": "^3.1.2",
"keycloak-js": "^19.0.1",
"moment": "^2.29.4",
- "next": "^13.4.6",
+ "next": "^14.1.1",
"prop-types": "^15.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
diff --git a/go.mod b/go.mod
index 5bedd8eb..8d9451d7 100644
--- a/go.mod
+++ b/go.mod
@@ -52,7 +52,7 @@ require (
go.uber.org/atomic v1.10.0 // indirect
golang.org/x/crypto v0.21.0 // indirect
golang.org/x/mod v0.16.0 // indirect
- golang.org/x/net v0.22.0 // indirect
+ golang.org/x/net v0.23.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.19.0 // indirect
diff --git a/go.sum b/go.sum
index d3742951..e81a7ed1 100644
--- a/go.sum
+++ b/go.sum
@@ -288,8 +288,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
-golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
+golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
+golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
diff --git a/pkg/agent/auth/auth.go b/pkg/agent/auth/auth.go
deleted file mode 100644
index 1a1d32c2..00000000
--- a/pkg/agent/auth/auth.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package auth
-
-import (
- "net/http"
-)
-
-type Auth interface {
- // Verify takes request and returns nil if allowed, err otherwise
- Verify(r *http.Request) error
-}
diff --git a/pkg/agent/auth/keycloak.go b/pkg/agent/auth/keycloak.go
deleted file mode 100644
index 7136d586..00000000
--- a/pkg/agent/auth/keycloak.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package auth
-
-import (
- "context"
- "fmt"
- "net/http"
- "os"
- "strings"
- "time"
-
- keyfunc "github.com/MicahParks/keyfunc/v2"
- jwt "github.com/golang-jwt/jwt/v5"
- "github.com/pardot/oidc/discovery"
- "github.com/pkg/errors"
-)
-
-type KeycloakVerifier struct {
- jwks *keyfunc.JWKS
- jwksURL string
- audience string
- api_permissions map[string][]string
- role_mappings map[string][]string
-}
-
-func getAuthLogic() (map[string][]string, map[string][]string) {
- // api call matches to list of strings, representing disjunction of requirements
- api_permissions := map[string][]string{
- // no auth token needed
- "/": []string{},
-
- // viewer
- "/api/healthcheck": []string{"admin", "viewer"},
- "/api/debugserver": []string{"admin", "viewer"},
- "/api/agent/list": []string{"admin", "viewer"},
- "/api/entry/list": []string{"admin", "viewer"},
- "/api/tornjak/serverinfo": []string{"admin", "viewer"},
- "/api/tornjak/selectors/list": []string{"admin", "viewer"},
- "/api/tornjak/agents/list": []string{"admin", "viewer"},
- "/api/tornjak/clusters/list": []string{"admin", "viewer"},
- // admin
- "/api/agent/ban": []string{"admin"},
- "/api/agent/delete": []string{"admin"},
- "/api/agent/createjointoken": []string{"admin"},
- "/api/entry/create": []string{"admin"},
- "/api/entry/delete": []string{"admin"},
- "/api/tornjak/selectors/register": []string{"admin"},
- "/api/tornjak/clusters/create": []string{"admin"},
- "/api/tornjak/clusters/edit": []string{"admin"},
- "/api/tornjak/clusters/delete": []string{"admin"},
- }
- role_mappings := map[string][]string{
- "tornjak-viewer-realm-role": []string{"viewer"},
- "tornjak-admin-realm-role": []string{"admin"},
- }
- return api_permissions, role_mappings
-}
-
-// newKeycloakVerifier (https bool, jwks string, redirect string)
-// get keyfunc based on https
-
-func getKeyFunc(httpjwks bool, jwksInfo string) (*keyfunc.JWKS, error) {
- if httpjwks {
- opts := keyfunc.Options{ // TODO add options to config file
- RefreshErrorHandler: func(err error) {
- fmt.Fprintf(os.Stdout, "error with jwt.Keyfunc: %v", err)
- },
- RefreshInterval: time.Hour,
- RefreshRateLimit: time.Minute * 5,
- RefreshTimeout: time.Second * 10,
- RefreshUnknownKID: true,
- }
- jwks, err := keyfunc.Get(jwksInfo, opts)
- if err != nil {
- return nil, errors.Errorf("Could not create Keyfunc for url %s: %v", jwksInfo, err)
- }
- return jwks, nil
- } else {
- jwks, err := keyfunc.NewJSON([]byte(jwksInfo))
- if err != nil {
- return nil, errors.Errorf("Could not create Keyfunc for json %s: %v", jwksInfo, err)
- }
- return jwks, nil
- }
-}
-
-func NewKeycloakVerifier(httpjwks bool, issuerURL string, audience string) (*KeycloakVerifier, error) {
- // perform OIDC discovery
- oidcClient, err := discovery.NewClient(context.Background(), issuerURL)
- if err != nil {
- return nil, errors.Errorf("Could not set up OIDC Discovery client with issuer = '%s': %v", issuerURL, err)
- }
- oidcClientMetadata := oidcClient.Metadata()
- jwksURL := oidcClientMetadata.JWKSURI
-
- // watch JWKS
- jwks, err := getKeyFunc(httpjwks, jwksURL)
- if err != nil {
- return nil, err
- }
- api_permissions, role_mappings := getAuthLogic()
- return &KeycloakVerifier{
- jwks: jwks,
- audience: audience,
- jwksURL: jwksURL,
- api_permissions: api_permissions,
- role_mappings: role_mappings,
- }, nil
-}
-
-func get_token(r *http.Request, redirectURL string) (string, error) {
- // Authorization paramter from HTTP header
- auth_header := r.Header.Get("Authorization")
- if auth_header == "" {
- return "", errors.Errorf("Authorization header missing. Please obtain access token here: %s", redirectURL)
- }
-
- // get bearer token
- auth_fields := strings.Fields(auth_header)
- if len(auth_fields) != 2 || auth_fields[0] != "Bearer" {
- return "", errors.Errorf("Expected bearer token, got %s", auth_header)
- } else {
- return auth_fields[1], nil
- }
-
-}
-
-func (v *KeycloakVerifier) getPermissions(jwt_roles []string) map[string]bool {
- permissions := make(map[string]bool)
-
- for _, r := range jwt_roles {
- for _, m := range v.role_mappings[r] {
- permissions[m] = true
- }
- }
-
- return permissions
-}
-
-func (v *KeycloakVerifier) requestPermissible(r *http.Request, permissions map[string]bool) bool {
- requires := v.api_permissions[r.URL.Path]
- for _, req := range requires {
- if _, ok := permissions[req]; ok {
- return true
- }
- }
- return false
-
-}
-
-func (v *KeycloakVerifier) isGoodRequest(r *http.Request, claims *KeycloakClaim) bool {
- roles := claims.RealmAccess.Roles
-
- permissions := v.getPermissions(roles)
- return v.requestPermissible(r, permissions)
-}
-
-func (v *KeycloakVerifier) needsAuthToken(r *http.Request) bool {
- requires := v.api_permissions[r.URL.Path]
- return len(requires) > 0
-}
-
-func (v *KeycloakVerifier) Verify(r *http.Request) error {
- // first check if is request does not need auth in our default policy
- needs_auth := v.needsAuthToken(r)
- if !needs_auth {
- return nil
- }
-
- token, err := get_token(r, v.jwksURL)
- if err != nil {
- return err
- }
-
- // parse token
- claims := &KeycloakClaim{}
- parserOptions := jwt.WithAudience(v.audience)
- jwt_token, err := jwt.ParseWithClaims(token, claims, v.jwks.Keyfunc, parserOptions)
- if err != nil {
- return errors.Errorf("Error parsing token: %s", err.Error())
- }
-
- // check token validity
- if !jwt_token.Valid {
- return errors.New("Token invalid")
- }
-
- // check roles
- if !v.isGoodRequest(r, claims) {
- return errors.New("Unauthorized request")
- }
-
- return nil
-}
diff --git a/pkg/agent/auth/keycloak_claim.go b/pkg/agent/auth/keycloak_claim.go
deleted file mode 100644
index 19e50308..00000000
--- a/pkg/agent/auth/keycloak_claim.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package auth
-
-import (
- jwt "github.com/golang-jwt/jwt/v5"
-)
-
-type RealmAccessSubclaim struct {
- Roles []string `json:"roles"`
-}
-
-type KeycloakClaim struct {
- RealmAccess RealmAccessSubclaim `json:"realm_access"`
- jwt.RegisteredClaims
-}
diff --git a/pkg/agent/auth/keycloak_test.go b/pkg/agent/auth/keycloak_test.go
deleted file mode 100644
index 11b28c98..00000000
--- a/pkg/agent/auth/keycloak_test.go
+++ /dev/null
@@ -1,95 +0,0 @@
-package auth
-
-import (
- //"github.com/pkg/errors"
- "bytes"
- "encoding/json"
- "flag"
- "fmt"
- "net/http"
- "testing"
- //"github.com/spiffe/tornjak/tornjak-backend/pkg/agent/types"
-)
-
-var issuerURL string
-
-func init() {
- flag.StringVar(&issuerURL, "jwksURL", "", "JWKS Url")
-}
-
-// TODO tests for Verify - currently AUTH logic too integrated to make general unit tests
-
-// TestNewKecyloakVerifier checks correctness of functions dealing with Agent Selector table
-// Uses functions NewKeycloakVerfier
-func TestNewKeycloakVerifier(t *testing.T) {
- // INIT failures
- _, err := NewKeycloakVerifier(true, "", "")
- if err == nil {
- t.Fatal("ERROR: successfully initialized keyfunc for empty issuer url")
- }
- _, err = NewKeycloakVerifier(true, "invalid url", "")
- if err == nil {
- t.Fatal("ERROR: successfully initialized keyfunc for invalid url")
- }
-
- _, err = NewKeycloakVerifier(false, "", "")
- if err == nil {
- t.Fatal("ERROR: successfully initialized keyfunc for empty jwks json")
- }
- // INIT success JSON
- sample_json := `{"keys":[{"kty":"RSA","e":"AQAB","use":"sig","kid":"MjhhMDk2N2M2NGEwMzgzYjk2OTI3YzdmMGVhOGYxNjI2OTc5Y2Y2MQ","alg":"RS256","n":"zZU9xSgK77PbtkjJgD2Vmmv6_QNe8B54eyOV0k5K2UwuSnhv9RyRA3aL7gDN-qkANemHw3H_4Tc5SKIMltVIYdWlOMW_2m3gDBOODjc1bE-WXEWX6nQkLAOkoFrGW3bgW8TFxfuwgZVTlb6cYkSyiwc5ueFV2xNqo96Qf7nm5E7KZ2QDTkSlNMdW-jIVHMKjuEsy_gtYMaEYrwk5N7VoiYwePaF3I0_g4G2tIrKTLb8DvHApsN1h-s7jMCQFBrY4vCf3RBlYULr4Nz7u8G2NL_L9vURSCU2V2A8rYRkoZoZwk3a3AyJiqeC4T_1rmb8XdrgeFHB5bzXZ7EI0TObhlw"}]}`
- _, err = NewKeycloakVerifier(false, sample_json, "")
- if err != nil {
- t.Fatalf("ERROR: could not create keyfunc from json: %v", err)
- }
-
- if issuerURL != "" {
- _, err = NewKeycloakVerifier(true, issuerURL, "")
- if err != nil {
- t.Fatalf("ERROR: could not create keyfunc from HTTP: %v", err)
- }
- } else {
- fmt.Printf("WARNING: not testing http jwks")
- }
-}
-
-func TestGetToken(t *testing.T) {
- // sample request with token
- request_body, err := json.Marshal(map[string]string{
- "name": "nobody",
- })
- if err != nil {
- t.Fatalf("ERROR: could not create request body")
- }
-
- // test with no Authorization header
- request, err := http.NewRequest("GET", "some/url", bytes.NewBuffer(request_body))
- if err != nil {
- t.Fatalf("ERROR: could not create request")
- }
- token, err := get_token(request, "redirecturl")
- if err == nil {
- t.Fatalf("ERROR: successfully obtained access token from request with no auth header: %s", token)
- }
-
- // test with Authorization header but no Bearer token
- request.Header.Set("Authorization", "something else")
- token, err = get_token(request, "redirecturl")
- if err == nil {
- t.Fatalf("ERROR: successfully obtained access token from request with no bearer token: %s", token)
- }
-
- // test with Authorization header but empty Bearer token
- request.Header.Set("Authorization", "Bearer ")
- token, err = get_token(request, "redirecturl")
- if err == nil {
- t.Fatalf("ERROR: successfully obtained access token from request with empty bearer token: %s", token)
- }
-
- // test with good Authorization header and bearer token
- request.Header.Set("Authorization", "Bearer ")
- token, err = get_token(request, "redirecturl")
- if err != nil {
- t.Fatalf("ERROR: could not obtain access token from request with bearer token: %s", token)
- }
-}
diff --git a/pkg/agent/auth/no_auth.go b/pkg/agent/auth/no_auth.go
deleted file mode 100644
index 599cb953..00000000
--- a/pkg/agent/auth/no_auth.go
+++ /dev/null
@@ -1,13 +0,0 @@
-package auth
-
-import "net/http"
-
-type NullVerifier struct{}
-
-func NewNullVerifier() *NullVerifier {
- return &NullVerifier{}
-}
-
-func (v *NullVerifier) Verify(r *http.Request) error {
- return nil
-}
diff --git a/pkg/agent/authentication/authenticator/authenticator.go b/pkg/agent/authentication/authenticator/authenticator.go
new file mode 100644
index 00000000..fc6210fc
--- /dev/null
+++ b/pkg/agent/authentication/authenticator/authenticator.go
@@ -0,0 +1,14 @@
+package authenticator
+
+import (
+ "net/http"
+
+ "github.com/spiffe/tornjak/pkg/agent/authentication/user"
+)
+
+type Authenticator interface {
+ // AuthenticateRequest takes request, verifies certain properties
+ // and returns relevant UserInfo to be interpreted by the Authorizer
+ // or error upon verification error
+ AuthenticateRequest(r *http.Request) (*user.UserInfo)
+}
diff --git a/pkg/agent/authentication/authenticator/keycloak.go b/pkg/agent/authentication/authenticator/keycloak.go
new file mode 100644
index 00000000..0a7571fa
--- /dev/null
+++ b/pkg/agent/authentication/authenticator/keycloak.go
@@ -0,0 +1,127 @@
+package authenticator
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ keyfunc "github.com/MicahParks/keyfunc/v2"
+ jwt "github.com/golang-jwt/jwt/v5"
+ "github.com/pardot/oidc/discovery"
+ "github.com/pkg/errors"
+
+ "github.com/spiffe/tornjak/pkg/agent/authentication/user"
+)
+
+type RealmAccessSubclaim struct {
+ Roles []string `json:"roles"`
+}
+
+type KeycloakClaim struct {
+ RealmAccess RealmAccessSubclaim `json:"realm_access"`
+ jwt.RegisteredClaims
+}
+
+type KeycloakAuthenticator struct {
+ jwks *keyfunc.JWKS
+ jwksURL string
+ audience string
+}
+
+func getJWKeyFunc(httpjwks bool, jwksInfo string) (*keyfunc.JWKS, error) {
+ if httpjwks {
+ opts := keyfunc.Options{ // TODO add options to config file
+ RefreshErrorHandler: func(err error) {
+ fmt.Fprintf(os.Stdout, "error with jwt.Keyfunc: %v", err)
+ },
+ RefreshInterval: time.Hour,
+ RefreshRateLimit: time.Minute * 5,
+ RefreshTimeout: time.Second * 10,
+ RefreshUnknownKID: true,
+ }
+ jwks, err := keyfunc.Get(jwksInfo, opts)
+ if err != nil {
+ return nil, errors.Errorf("Could not create Keyfunc for url %s: %v", jwksInfo, err)
+ }
+ return jwks, nil
+ } else {
+ jwks, err := keyfunc.NewJSON([]byte(jwksInfo))
+ if err != nil {
+ return nil, errors.Errorf("Could not create Keyfunc for json %s: %v", jwksInfo, err)
+ }
+ return jwks, nil
+ }
+}
+
+// newKeycloakAuthenticator (https bool, jwks string, redirect string)
+// get keyfunc based on https
+func NewKeycloakAuthenticator(httpjwks bool, issuerURL string, audience string) (*KeycloakAuthenticator, error) {
+ // perform OIDC discovery
+ oidcClient, err := discovery.NewClient(context.Background(), issuerURL)
+ if err != nil {
+ return nil, errors.Errorf("Could not set up OIDC Discovery client with issuer = '%s': %v", issuerURL, err)
+ }
+ oidcClientMetadata := oidcClient.Metadata()
+ jwksURL := oidcClientMetadata.JWKSURI
+
+ // watch JWKS
+ jwks, err := getJWKeyFunc(httpjwks, jwksURL)
+ if err != nil {
+ return nil, err
+ }
+ return &KeycloakAuthenticator{
+ jwks: jwks,
+ audience: audience,
+ jwksURL: jwksURL,
+ }, nil
+}
+
+func getToken(r *http.Request, redirectURL string) (string, error) {
+ // Authorization parameter from HTTP header
+ auth_header := r.Header.Get("Authorization")
+ if auth_header == "" {
+ return "", errors.Errorf("Authorization header missing. Please obtain access token here: %s", redirectURL)
+ }
+
+ // get bearer token
+ auth_fields := strings.Fields(auth_header)
+ if len(auth_fields) != 2 || auth_fields[0] != "Bearer" {
+ return "", errors.Errorf("Expected bearer token, got %s", auth_header)
+ } else {
+ return auth_fields[1], nil
+ }
+
+}
+
+func wrapAuthenticationError(err error) (*user.UserInfo) {
+ return &user.UserInfo{
+ AuthenticationError: err,
+ }
+}
+
+func (a *KeycloakAuthenticator) AuthenticateRequest(r *http.Request)(*user.UserInfo) {
+ token, err := getToken(r, a.jwksURL)
+ if err != nil {
+ return wrapAuthenticationError(err)
+ }
+
+ // parse token
+ claims := &KeycloakClaim{}
+ parserOptions := jwt.WithAudience(a.audience)
+ jwt_token, err := jwt.ParseWithClaims(token, claims, a.jwks.Keyfunc, parserOptions)
+ if err != nil {
+ return wrapAuthenticationError(errors.Errorf("Error parsing token :%s", err.Error()))
+ }
+
+ // check token validity
+ if !jwt_token.Valid {
+ return wrapAuthenticationError(errors.New("Token invalid"))
+ }
+
+ return &user.UserInfo{
+ Roles: claims.RealmAccess.Roles,
+ }
+}
diff --git a/pkg/agent/authentication/authenticator/null_authenticator.go b/pkg/agent/authentication/authenticator/null_authenticator.go
new file mode 100644
index 00000000..9fadd254
--- /dev/null
+++ b/pkg/agent/authentication/authenticator/null_authenticator.go
@@ -0,0 +1,16 @@
+package authenticator
+
+import (
+ "net/http"
+
+ "github.com/spiffe/tornjak/pkg/agent/authentication/user"
+)
+
+type NullAuthenticator struct{}
+
+func NewNullAuthenticator() *NullAuthenticator {
+ return &NullAuthenticator{}
+}
+func (a *NullAuthenticator) AuthenticateRequest(r *http.Request) (*user.UserInfo) {
+ return nil
+}
diff --git a/pkg/agent/authentication/user/user.go b/pkg/agent/authentication/user/user.go
new file mode 100644
index 00000000..c5d1c562
--- /dev/null
+++ b/pkg/agent/authentication/user/user.go
@@ -0,0 +1,6 @@
+package user
+
+type UserInfo struct {
+ AuthenticationError error
+ Roles []string
+}
diff --git a/pkg/agent/authorization/authorization.go b/pkg/agent/authorization/authorization.go
new file mode 100644
index 00000000..3bf833e3
--- /dev/null
+++ b/pkg/agent/authorization/authorization.go
@@ -0,0 +1,12 @@
+package authorization
+
+import (
+ "net/http"
+
+ "github.com/spiffe/tornjak/pkg/agent/authentication/user"
+)
+
+type Authorizer interface {
+ // Authorize Request
+ AuthorizeRequest(r *http.Request, u *user.UserInfo) error
+}
\ No newline at end of file
diff --git a/pkg/agent/authorization/null_authorizer.go b/pkg/agent/authorization/null_authorizer.go
new file mode 100644
index 00000000..13a57dde
--- /dev/null
+++ b/pkg/agent/authorization/null_authorizer.go
@@ -0,0 +1,16 @@
+package authorization
+
+import (
+ "net/http"
+
+ "github.com/spiffe/tornjak/pkg/agent/authentication/user"
+)
+
+type NullAuthorizer struct{}
+
+func NewNullAuthorizer() *NullAuthorizer {
+ return &NullAuthorizer{}
+}
+func (a *NullAuthorizer) AuthorizeRequest(r *http.Request, u *user.UserInfo) (error) {
+ return nil
+}
\ No newline at end of file
diff --git a/pkg/agent/authorization/rbac.go b/pkg/agent/authorization/rbac.go
new file mode 100644
index 00000000..0b1e1fe4
--- /dev/null
+++ b/pkg/agent/authorization/rbac.go
@@ -0,0 +1,97 @@
+package authorization
+
+import (
+ "net/http"
+ "github.com/pkg/errors"
+
+ "github.com/spiffe/tornjak/pkg/agent/authentication/user"
+)
+
+type RBACAuthorizer struct {
+ name string
+ roleList map[string]string
+ apiMapping map[string][]string
+}
+
+// TODO put this in a common constants file
+var staticAPIList = map[string]struct{}{
+ "/": {},
+ "/api/healthcheck": {},
+ "/api/debugserver": {},
+ "/api/agent/list": {},
+ "/api/entry/list": {},
+ "/api/tornjak/serverinfo": {},
+ "/api/tornjak/selectors/list": {},
+ "/api/tornjak/agents/list": {},
+ "/api/tornjak/clusters/list": {},
+ "/api/agent/ban": {},
+ "/api/agent/delete": {},
+ "/api/agent/createjointoken": {},
+ "/api/entry/create": {},
+ "/api/entry/delete": {},
+ "/api/tornjak/selectors/register": {},
+ "/api/tornjak/clusters/create": {},
+ "/api/tornjak/clusters/edit": {},
+ "/api/tornjak/clusters/delete": {},
+}
+
+func validateInitParameters(roleList map[string]string, apiMapping map[string][]string) error {
+ for api, allowList := range apiMapping {
+ // check that API exists
+ if _, ok := staticAPIList[api]; !ok {
+ return errors.Errorf("API %s does not exist", api)
+ }
+
+ // check that each role exists in roleList
+ for _, allowedRole := range allowList {
+ if _, ok := roleList[allowedRole]; !ok {
+ return errors.Errorf("API %s lists undefined role %s", api, allowedRole)
+ }
+ }
+ }
+ return nil
+}
+
+func NewRBACAuthorizer(policyName string, roleList map[string]string, apiMapping map[string][]string) (*RBACAuthorizer, error) {
+ err := validateInitParameters(roleList, apiMapping)
+ if err != nil {
+ return nil, errors.Errorf("Could not parse policy %s: invalid mapping: %v", policyName, err)
+ }
+ return &RBACAuthorizer{
+ name: policyName,
+ roleList: roleList,
+ apiMapping: apiMapping,
+ }, nil
+}
+
+func (a *RBACAuthorizer) AuthorizeRequest(r *http.Request, u *user.UserInfo) error {
+ // if not authenticated fail and return error
+ if u.AuthenticationError != nil {
+ return errors.Errorf("Authentication error: %v", u.AuthenticationError)
+ }
+
+ userRoles := u.Roles
+ apiPath := r.URL.Path
+
+ allowedRoles := a.apiMapping[apiPath]
+
+ // if no role listed for api, reject
+ if len(allowedRoles) == 0 {
+ return errors.New("Unauthorized request")
+ }
+
+ // check each allowed role
+ for _, allowedRole := range allowedRoles {
+ if allowedRole == "" { // all authenticated allowed
+ return nil
+ }
+ for _, role := range userRoles {
+ // user has role
+ if role == allowedRole {
+ return nil
+ }
+ }
+ }
+
+ return errors.New("Unauthorized request")
+}
diff --git a/version.txt b/version.txt
index b7c0a9b1..a20e2d82 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-v1.6.0
+v1.7.0