From c3f7945284b52b3a60da06a27f13c4ce43e0c310 Mon Sep 17 00:00:00 2001 From: Tommie Gannert Date: Mon, 23 May 2022 17:38:30 +0200 Subject: [PATCH] Add SSO UserAPI endpoints. This is mostly copied from the ThirdPID, but with a primary key that matches OpenID Connect nomenclature. There's a namspace to ensure other SSO solutions can be supported, but there's only one namespace defined for now. --- userapi/api/api.go | 1 + userapi/api/api_sso.go | 53 +++++++++++++++ userapi/api/api_trace_sso.go | 39 +++++++++++ userapi/internal/api_sso.go | 50 ++++++++++++++ userapi/inthttp/client_sso.go | 53 +++++++++++++++ userapi/inthttp/server.go | 1 + userapi/inthttp/server_sso.go | 66 +++++++++++++++++++ userapi/storage/interface.go | 7 ++ userapi/storage/postgres/sso_table.go | 93 +++++++++++++++++++++++++++ userapi/storage/shared/storage.go | 30 +++++++++ userapi/storage/sqlite3/sso_table.go | 93 +++++++++++++++++++++++++++ userapi/storage/tables/interface.go | 6 ++ 12 files changed, 492 insertions(+) create mode 100644 userapi/api/api_sso.go create mode 100644 userapi/api/api_trace_sso.go create mode 100644 userapi/internal/api_sso.go create mode 100644 userapi/inthttp/client_sso.go create mode 100644 userapi/inthttp/server_sso.go create mode 100644 userapi/storage/postgres/sso_table.go create mode 100644 userapi/storage/sqlite3/sso_table.go diff --git a/userapi/api/api.go b/userapi/api/api.go index df9408acb..ac38927bb 100644 --- a/userapi/api/api.go +++ b/userapi/api/api.go @@ -77,6 +77,7 @@ type SyncUserAPI interface { type ClientUserAPI interface { QueryAcccessTokenAPI LoginTokenInternalAPI + SSOAPI UserLoginAPI QueryNumericLocalpart(ctx context.Context, res *QueryNumericLocalpartResponse) error QueryDevices(ctx context.Context, req *QueryDevicesRequest, res *QueryDevicesResponse) error diff --git a/userapi/api/api_sso.go b/userapi/api/api_sso.go new file mode 100644 index 000000000..56a3686b9 --- /dev/null +++ b/userapi/api/api_sso.go @@ -0,0 +1,53 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" +) + +type SSOAPI interface { + QueryLocalpartForSSO(ctx context.Context, req *QueryLocalpartForSSORequest, res *QueryLocalpartForSSOResponse) error + PerformForgetSSO(ctx context.Context, req *PerformForgetSSORequest, res *struct{}) error + PerformSaveSSOAssociation(ctx context.Context, req *PerformSaveSSOAssociationRequest, res *struct{}) error +} + +type QueryLocalpartForSSORequest struct { + Namespace SSOIssuerNamespace + Issuer, Subject string +} + +type QueryLocalpartForSSOResponse struct { + Localpart string +} + +type PerformForgetSSORequest QueryLocalpartForSSORequest + +type PerformSaveSSOAssociationRequest struct { + Namespace SSOIssuerNamespace + Issuer, Subject string + Localpart string +} + +// An SSOIssuerNamespace defines the interpretation of an issuer. +type SSOIssuerNamespace string + +const ( + UnknownNamespace SSOIssuerNamespace = "" + + // OIDCNamespace indicates the issuer is a full URL, as defined in + // https://openid.net/specs/openid-connect-core-1_0.html#Terminology. + OIDCNamespace SSOIssuerNamespace = "oidc" +) diff --git a/userapi/api/api_trace_sso.go b/userapi/api/api_trace_sso.go new file mode 100644 index 000000000..0a836f84a --- /dev/null +++ b/userapi/api/api_trace_sso.go @@ -0,0 +1,39 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "context" + + "github.com/matrix-org/util" +) + +func (t *UserInternalAPITrace) QueryLocalpartForSSO(ctx context.Context, req *QueryLocalpartForSSORequest, res *QueryLocalpartForSSOResponse) error { + err := t.Impl.QueryLocalpartForSSO(ctx, req, res) + util.GetLogger(ctx).Infof("QueryLocalpartForSSO req=%+v res=%+v", js(req), js(res)) + return err +} + +func (t *UserInternalAPITrace) PerformForgetSSO(ctx context.Context, req *PerformForgetSSORequest, res *struct{}) error { + err := t.Impl.PerformForgetSSO(ctx, req, res) + util.GetLogger(ctx).Infof("PerformForgetSSO req=%+v res=%+v", js(req), js(res)) + return err +} + +func (t *UserInternalAPITrace) PerformSaveSSOAssociation(ctx context.Context, req *PerformSaveSSOAssociationRequest, res *struct{}) error { + err := t.Impl.PerformSaveSSOAssociation(ctx, req, res) + util.GetLogger(ctx).Infof("PerformSaveSSOAssociation req=%+v res=%+v", js(req), js(res)) + return err +} diff --git a/userapi/internal/api_sso.go b/userapi/internal/api_sso.go new file mode 100644 index 000000000..171c31872 --- /dev/null +++ b/userapi/internal/api_sso.go @@ -0,0 +1,50 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package internal + +import ( + "context" + "fmt" + + "github.com/matrix-org/dendrite/userapi/api" +) + +func (a *UserInternalAPI) QueryLocalpartForSSO(ctx context.Context, req *api.QueryLocalpartForSSORequest, res *api.QueryLocalpartForSSOResponse) error { + var err error + res.Localpart, err = a.DB.GetLocalpartForSSO(ctx, string(req.Namespace), req.Issuer, req.Subject) + return err +} + +func (a *UserInternalAPI) PerformForgetSSO(ctx context.Context, req *api.PerformForgetSSORequest, res *struct{}) error { + return a.DB.RemoveSSOAssociation(ctx, string(req.Namespace), req.Issuer, req.Subject) +} + +func (a *UserInternalAPI) PerformSaveSSOAssociation(ctx context.Context, req *api.PerformSaveSSOAssociationRequest, res *struct{}) error { + ns, err := validateSSOIssuerNamespace(req.Namespace) + if err != nil { + return err + } + return a.DB.SaveSSOAssociation(ctx, ns, req.Issuer, req.Subject, req.Localpart) +} + +func validateSSOIssuerNamespace(ns api.SSOIssuerNamespace) (string, error) { + switch ns { + case api.OIDCNamespace: + return string(ns), nil + + default: + return "", fmt.Errorf("invalid SSO issuer namespace: %s", ns) + } +} diff --git a/userapi/inthttp/client_sso.go b/userapi/inthttp/client_sso.go new file mode 100644 index 000000000..e34130ed0 --- /dev/null +++ b/userapi/inthttp/client_sso.go @@ -0,0 +1,53 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package inthttp + +import ( + "context" + + "github.com/matrix-org/dendrite/internal/httputil" + "github.com/matrix-org/dendrite/userapi/api" + "github.com/opentracing/opentracing-go" +) + +const ( + PerformForgetSSOPath = "/userapi/performForgetSSO" + PerformSaveSSOAssociationPath = "/userapi/performSaveSSOAssociation" + QueryLocalpartForSSOPath = "/userapi/queryLocalpartForSSO" +) + +func (h *httpUserInternalAPI) QueryLocalpartForSSO(ctx context.Context, req *api.QueryLocalpartForSSORequest, res *api.QueryLocalpartForSSOResponse) error { + span, ctx := opentracing.StartSpanFromContext(ctx, QueryLocalpartForSSOPath) + defer span.Finish() + + apiURL := h.apiURL + QueryLocalpartForSSOPath + return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) +} + +func (h *httpUserInternalAPI) PerformForgetSSO(ctx context.Context, req *api.PerformForgetSSORequest, res *struct{}) error { + span, ctx := opentracing.StartSpanFromContext(ctx, PerformForgetSSOPath) + defer span.Finish() + + apiURL := h.apiURL + PerformForgetSSOPath + return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) +} + +func (h *httpUserInternalAPI) PerformSaveSSOAssociation(ctx context.Context, req *api.PerformSaveSSOAssociationRequest, res *struct{}) error { + span, ctx := opentracing.StartSpanFromContext(ctx, PerformSaveSSOAssociationPath) + defer span.Finish() + + apiURL := h.apiURL + PerformSaveSSOAssociationPath + return httputil.PostJSON(ctx, span, h.httpClient, apiURL, req, res) +} diff --git a/userapi/inthttp/server.go b/userapi/inthttp/server.go index ad532b901..2ed7a1d69 100644 --- a/userapi/inthttp/server.go +++ b/userapi/inthttp/server.go @@ -28,6 +28,7 @@ import ( // nolint: gocyclo func AddRoutes(internalAPIMux *mux.Router, s api.UserInternalAPI) { addRoutesLoginToken(internalAPIMux, s) + addRoutesSSO(internalAPIMux, s) internalAPIMux.Handle(PerformAccountCreationPath, httputil.MakeInternalAPI("performAccountCreation", func(req *http.Request) util.JSONResponse { diff --git a/userapi/inthttp/server_sso.go b/userapi/inthttp/server_sso.go new file mode 100644 index 000000000..6e362a970 --- /dev/null +++ b/userapi/inthttp/server_sso.go @@ -0,0 +1,66 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package inthttp + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/matrix-org/dendrite/internal/httputil" + "github.com/matrix-org/dendrite/userapi/api" + "github.com/matrix-org/util" +) + +// addRoutesSSO adds routes for all SSO API calls. +func addRoutesSSO(internalAPIMux *mux.Router, s api.UserInternalAPI) { + internalAPIMux.Handle(QueryLocalpartForSSOPath, + httputil.MakeInternalAPI("queryLocalpartForSSO", func(req *http.Request) util.JSONResponse { + request := api.QueryLocalpartForSSORequest{} + response := api.QueryLocalpartForSSOResponse{} + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.MessageResponse(http.StatusBadRequest, err.Error()) + } + if err := s.QueryLocalpartForSSO(req.Context(), &request, &response); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &response} + }), + ) + internalAPIMux.Handle(PerformForgetSSOPath, + httputil.MakeInternalAPI("performForgetSSO", func(req *http.Request) util.JSONResponse { + request := api.PerformForgetSSORequest{} + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.MessageResponse(http.StatusBadRequest, err.Error()) + } + if err := s.PerformForgetSSO(req.Context(), &request, &struct{}{}); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &struct{}{}} + }), + ) + internalAPIMux.Handle(PerformSaveSSOAssociationPath, + httputil.MakeInternalAPI("performSaveSSOAssociation", func(req *http.Request) util.JSONResponse { + request := api.PerformSaveSSOAssociationRequest{} + if err := json.NewDecoder(req.Body).Decode(&request); err != nil { + return util.MessageResponse(http.StatusBadRequest, err.Error()) + } + if err := s.PerformSaveSSOAssociation(req.Context(), &request, &struct{}{}); err != nil { + return util.ErrorResponse(err) + } + return util.JSONResponse{Code: http.StatusOK, JSON: &struct{}{}} + }), + ) +} diff --git a/userapi/storage/interface.go b/userapi/storage/interface.go index f7cd1810a..9ddb0f9b9 100644 --- a/userapi/storage/interface.go +++ b/userapi/storage/interface.go @@ -109,6 +109,12 @@ type Pusher interface { RemovePushers(ctx context.Context, appid, pushkey string) error } +type SSO interface { + SaveSSOAssociation(ctx context.Context, namespace, iss, sub, localpart string) error + RemoveSSOAssociation(ctx context.Context, namespace, iss, sub string) error + GetLocalpartForSSO(ctx context.Context, namespace, iss, sub string) (string, error) +} + type ThreePID interface { SaveThreePIDAssociation(ctx context.Context, threepid, localpart, medium string) (err error) RemoveThreePIDAssociation(ctx context.Context, threepid string, medium string) (err error) @@ -136,6 +142,7 @@ type Database interface { OpenID Profile Pusher + SSO Statistics ThreePID } diff --git a/userapi/storage/postgres/sso_table.go b/userapi/storage/postgres/sso_table.go new file mode 100644 index 000000000..b04e69f55 --- /dev/null +++ b/userapi/storage/postgres/sso_table.go @@ -0,0 +1,93 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package postgres + +import ( + "context" + "database/sql" + + "github.com/matrix-org/dendrite/internal/sqlutil" + "github.com/matrix-org/dendrite/userapi/storage/tables" +) + +const ssoSchema = ` +-- Stores data about SSO associations. +CREATE TABLE IF NOT EXISTS account_sso ( + -- The "iss" namespace. Must be "oidc". + namespace TEXT NOT NULL, + -- The issuer; for "oidc", a URL. + iss TEXT NOT NULL, + -- The subject (user ID). + sub TEXT NOT NULL, + -- The localpart of the Matrix user ID associated to this 3PID + localpart TEXT NOT NULL, + + PRIMARY KEY(namespace, iss, sub) +); +` + +const selectLocalpartForSSOSQL = "" + + "SELECT localpart FROM account_sso WHERE namespace = $1 AND iss = $2 AND sub = $3" + +const insertSSOSQL = "" + + "INSERT INTO account_sso (namespace, iss, sub, localpart) VALUES ($1, $2, $3, $4)" + +const deleteSSOSQL = "" + + "DELETE FROM account_sso WHERE namespace = $1 AND iss = $2 AND sub = $3" + +type ssoStatements struct { + selectLocalpartForSSOStmt *sql.Stmt + insertSSOStmt *sql.Stmt + deleteSSOStmt *sql.Stmt +} + +func NewPostgresSSOTable(db *sql.DB) (tables.SSOTable, error) { + s := &ssoStatements{} + _, err := db.Exec(ssoSchema) + if err != nil { + return nil, err + } + return s, sqlutil.StatementList{ + {&s.selectLocalpartForSSOStmt, selectLocalpartForSSOSQL}, + {&s.insertSSOStmt, insertSSOSQL}, + {&s.deleteSSOStmt, deleteSSOSQL}, + }.Prepare(db) +} + +func (s *ssoStatements) SelectLocalpartForSSO( + ctx context.Context, txn *sql.Tx, namespace, iss, sub string, +) (localpart string, err error) { + stmt := sqlutil.TxStmt(txn, s.selectLocalpartForSSOStmt) + err = stmt.QueryRowContext(ctx, namespace, iss, sub).Scan(&localpart) + if err == sql.ErrNoRows { + return "", nil + } + return +} + +func (s *ssoStatements) InsertSSO( + ctx context.Context, txn *sql.Tx, namespace, iss, sub, localpart string, +) (err error) { + stmt := sqlutil.TxStmt(txn, s.insertSSOStmt) + _, err = stmt.ExecContext(ctx, namespace, iss, sub, localpart) + return +} + +func (s *ssoStatements) DeleteSSO( + ctx context.Context, txn *sql.Tx, namespace, iss, sub string) (err error) { + stmt := sqlutil.TxStmt(txn, s.deleteSSOStmt) + _, err = stmt.ExecContext(ctx, namespace, iss, sub) + return +} diff --git a/userapi/storage/shared/storage.go b/userapi/storage/shared/storage.go index 0cf713dac..c44968406 100644 --- a/userapi/storage/shared/storage.go +++ b/userapi/storage/shared/storage.go @@ -52,6 +52,7 @@ type Database struct { LoginTokens tables.LoginTokenTable Notifications tables.NotificationTable Pushers tables.PusherTable + SSOs tables.SSOTable Stats tables.StatsTable LoginTokenLifetime time.Duration ServerName gomatrixserverlib.ServerName @@ -225,6 +226,35 @@ func (d *Database) hashPassword(plaintext string) (hash string, err error) { return string(hashBytes), err } +var ErrSSOInUse = errors.New("this SSO account is already in use") + +func (d *Database) SaveSSOAssociation(ctx context.Context, namespace, iss, sub, localpart string) error { + return d.Writer.Do(d.DB, nil, func(txn *sql.Tx) error { + user, err := d.SSOs.SelectLocalpartForSSO( + ctx, txn, namespace, iss, sub, + ) + if err != nil { + return err + } + + if len(user) > 0 { + return Err3PIDInUse + } + + return d.SSOs.InsertSSO(ctx, txn, namespace, iss, sub, localpart) + }) +} + +func (d *Database) RemoveSSOAssociation(ctx context.Context, namespace, iss, sub string) error { + return d.Writer.Do(d.DB, nil, func(txn *sql.Tx) error { + return d.SSOs.DeleteSSO(ctx, txn, namespace, iss, sub) + }) +} + +func (d *Database) GetLocalpartForSSO(ctx context.Context, namespace, iss, sub string) (string, error) { + return d.SSOs.SelectLocalpartForSSO(ctx, nil, namespace, iss, sub) +} + // Err3PIDInUse is the error returned when trying to save an association involving // a third-party identifier which is already associated to a local user. var Err3PIDInUse = errors.New("this third-party identifier is already in use") diff --git a/userapi/storage/sqlite3/sso_table.go b/userapi/storage/sqlite3/sso_table.go new file mode 100644 index 000000000..16aede65b --- /dev/null +++ b/userapi/storage/sqlite3/sso_table.go @@ -0,0 +1,93 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sqlite3 + +import ( + "context" + "database/sql" + + "github.com/matrix-org/dendrite/internal/sqlutil" + "github.com/matrix-org/dendrite/userapi/storage/tables" +) + +const ssoSchema = ` +-- Stores data about SSO associations. +CREATE TABLE IF NOT EXISTS account_sso ( + -- The "iss" namespace. Must be "oidc". + namespace TEXT NOT NULL, + -- The issuer; for "oidc", a URL. + iss TEXT NOT NULL, + -- The subject (user ID). + sub TEXT NOT NULL, + -- The localpart of the Matrix user ID associated to this 3PID + localpart TEXT NOT NULL, + + PRIMARY KEY(namespace, iss, sub) +); +` + +const selectLocalpartForSSOSQL = "" + + "SELECT localpart FROM account_sso WHERE namespace = $1 AND iss = $2 AND sub = $3" + +const insertSSOSQL = "" + + "INSERT INTO account_sso (namespace, iss, sub, localpart) VALUES ($1, $2, $3, $4)" + +const deleteSSOSQL = "" + + "DELETE FROM account_sso WHERE namespace = $1 AND iss = $2 AND sub = $3" + +type ssoStatements struct { + selectLocalpartForSSOStmt *sql.Stmt + insertSSOStmt *sql.Stmt + deleteSSOStmt *sql.Stmt +} + +func NewSQLiteSSOTable(db *sql.DB) (tables.SSOTable, error) { + s := &ssoStatements{} + _, err := db.Exec(ssoSchema) + if err != nil { + return nil, err + } + return s, sqlutil.StatementList{ + {&s.selectLocalpartForSSOStmt, selectLocalpartForSSOSQL}, + {&s.insertSSOStmt, insertSSOSQL}, + {&s.deleteSSOStmt, deleteSSOSQL}, + }.Prepare(db) +} + +func (s *ssoStatements) SelectLocalpartForSSO( + ctx context.Context, txn *sql.Tx, namespace, iss, sub string, +) (localpart string, err error) { + stmt := sqlutil.TxStmt(txn, s.selectLocalpartForSSOStmt) + err = stmt.QueryRowContext(ctx, namespace, iss, sub).Scan(&localpart) + if err == sql.ErrNoRows { + return "", nil + } + return +} + +func (s *ssoStatements) InsertSSO( + ctx context.Context, txn *sql.Tx, namespace, iss, sub, localpart string, +) (err error) { + stmt := sqlutil.TxStmt(txn, s.insertSSOStmt) + _, err = stmt.ExecContext(ctx, namespace, iss, sub, localpart) + return +} + +func (s *ssoStatements) DeleteSSO( + ctx context.Context, txn *sql.Tx, namespace, iss, sub string) (err error) { + stmt := sqlutil.TxStmt(txn, s.deleteSSOStmt) + _, err = stmt.ExecContext(ctx, namespace, iss, sub) + return +} diff --git a/userapi/storage/tables/interface.go b/userapi/storage/tables/interface.go index 2fe955670..d0473809c 100644 --- a/userapi/storage/tables/interface.go +++ b/userapi/storage/tables/interface.go @@ -113,6 +113,12 @@ type NotificationTable interface { SelectRoomCounts(ctx context.Context, txn *sql.Tx, localpart, roomID string) (total int64, highlight int64, _ error) } +type SSOTable interface { + SelectLocalpartForSSO(ctx context.Context, txn *sql.Tx, namespace, iss, sub string) (string, error) + InsertSSO(ctx context.Context, txn *sql.Tx, namespace, iss, sub, localpart string) error + DeleteSSO(ctx context.Context, txn *sql.Tx, namespace, iss, sub string) error +} + type StatsTable interface { UserStatistics(ctx context.Context, txn *sql.Tx) (*types.UserStatistics, *types.DatabaseEngine, error) UpdateUserDailyVisits(ctx context.Context, txn *sql.Tx, startTime, lastUpdate time.Time) error