From 4436def28641095b45a896d7e918b1783d13dc66 Mon Sep 17 00:00:00 2001 From: Drew DeVault Date: Tue, 1 Nov 2022 12:24:06 +0100 Subject: [PATCH] Implement user account deletion --- api/account/middleware.go | 110 ++++++++++++++++++++++++++++++++++ api/graph/schema.graphqls | 11 ++++ api/graph/schema.resolvers.go | 8 +++ api/repos/middleware.go | 63 +++++++++++-------- api/server.go | 10 +++- gitsrht-dispatch/main.go | 10 ++-- gitsrht-keys/main.go | 2 +- schema.sql | 20 +++---- 8 files changed, 191 insertions(+), 43 deletions(-) create mode 100644 api/account/middleware.go diff --git a/api/account/middleware.go b/api/account/middleware.go new file mode 100644 index 0000000..64366a0 --- /dev/null +++ b/api/account/middleware.go @@ -0,0 +1,110 @@ +package account + +import ( + "context" + "database/sql" + "log" + "net/http" + "os" + "path" + + "git.sr.ht/~sircmpwn/core-go/config" + "git.sr.ht/~sircmpwn/core-go/database" + work "git.sr.ht/~sircmpwn/dowork" + "git.sr.ht/~sircmpwn/git.sr.ht/api/repos" +) + +type contextKey struct { + name string +} + +var ctxKey = &contextKey{"account"} + +func Middleware(queue *work.Queue) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), ctxKey, queue) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) + } +} + +// Schedules a user account deletion. +func Delete(ctx context.Context, userID int, username string) { + queue, ok := ctx.Value(ctxKey).(*work.Queue) + if !ok { + panic("No account worker for this context") + } + + type Artifact struct { + Filename string + RepoName string + } + + conf := config.ForContext(ctx) + repoStore, ok := conf.Get("git.sr.ht", "repos") + + task := work.NewTask(func(ctx context.Context) error { + log.Printf("Processing deletion of user account %d %s", userID, username) + var artifacts []Artifact + if err := database.WithTx(ctx, &sql.TxOptions{ + Isolation: 0, + ReadOnly: true, + }, func(tx *sql.Tx) error { + rows, err := tx.QueryContext(ctx, ` + SELECT r.name, a.filename + FROM artifacts a + JOIN repository r ON a.repo_id = r.id + WHERE a.user_id = $1 + `, userID) + if err != nil { + return err + } + + for rows.Next() { + var ( + filename string + repoName string + ) + if err := rows.Scan(&repoName, &filename); err != nil { + return err + } + artifacts = append(artifacts, Artifact{ + Filename: filename, + RepoName: repoName, + }) + } + if err := rows.Err(); err != nil { + return err + } + + return nil + }); err != nil { + return err + } + + for _, art := range artifacts { + repos.DeleteArtifactsBlocking(ctx, username, + art.RepoName, []string{art.Filename}) + } + userPath := path.Join(repoStore, "~"+username) + if err := os.RemoveAll(userPath); err != nil { + log.Printf("Failed to remove %s: %s", userPath, err.Error()) + } + + if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + DELETE FROM "user" WHERE id = $1 + `, userID) + return err + }); err != nil { + return err + } + + log.Printf("Deletion of user account %d %s complete", userID, username) + return nil + }) + queue.Enqueue(task) + log.Printf("Enqueued deletion of user account %d %s", userID, username) +} diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index e13016e..37df6c9 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -13,6 +13,12 @@ access token, and are not available to clients using OAuth 2.0 access tokens. """ directive @private on FIELD_DEFINITION +""" +This used to decorate fields which are for internal use, and are not +available to normal API users. +""" +directive @internal on FIELD_DEFINITION + enum AccessScope { PROFILE @scopehelp(details: "profile information") REPOSITORIES @scopehelp(details: "repository metadata") @@ -567,4 +573,9 @@ type Mutation { unexpected behavior with the third-party integration. """ deleteWebhook(id: Int!): WebhookSubscription + + """ + Deletes the authenticated user's account. Internal use only. + """ + deleteUser: Int! @internal } diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 7ed274e..d281a28 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -28,6 +28,7 @@ import ( "git.sr.ht/~sircmpwn/core-go/server" "git.sr.ht/~sircmpwn/core-go/valid" corewebhooks "git.sr.ht/~sircmpwn/core-go/webhooks" + "git.sr.ht/~sircmpwn/git.sr.ht/api/account" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/api" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model" "git.sr.ht/~sircmpwn/git.sr.ht/api/loaders" @@ -857,6 +858,13 @@ func (r *mutationResolver) DeleteWebhook(ctx context.Context, id int) (model.Web return &sub, nil } +// DeleteUser is the resolver for the deleteUser field. +func (r *mutationResolver) DeleteUser(ctx context.Context) (int, error) { + user := auth.ForContext(ctx) + account.Delete(ctx, user.UserID, user.Username) + return user.UserID, nil +} + // Version is the resolver for the version field. func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) { conf := config.ForContext(ctx) diff --git a/api/repos/middleware.go b/api/repos/middleware.go index b9d6593..c81e3d9 100644 --- a/api/repos/middleware.go +++ b/api/repos/middleware.go @@ -49,7 +49,8 @@ func Clone(ctx context.Context, repoID int, repo *git.Repository, cloneURL strin panic("No repos worker for this context") } task := work.NewTask(func(ctx context.Context) error { - cloneCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + log.Printf("Processing clone of %s", cloneURL) + cloneCtx, cancel := context.WithTimeout(ctx, 30*time.Minute) defer cancel() err := repo.Clone(cloneCtx, &git.CloneOptions{ URL: cloneURL, @@ -74,6 +75,7 @@ func Clone(ctx context.Context, repoID int, repo *git.Repository, cloneURL strin }); err != nil { panic(err) } + log.Printf("Clone %s complete", cloneURL) return nil }) queue.Enqueue(task) @@ -87,33 +89,42 @@ func DeleteArtifacts(ctx context.Context, username, repoName string, filenames [ panic("No repos worker for this context") } task := work.NewTask(func(ctx context.Context) error { - conf := config.ForContext(ctx) - upstream, _ := conf.Get("objects", "s3-upstream") - accessKey, _ := conf.Get("objects", "s3-access-key") - secretKey, _ := conf.Get("objects", "s3-secret-key") - bucket, _ := conf.Get("git.sr.ht", "s3-bucket") - prefix, _ := conf.Get("git.sr.ht", "s3-prefix") + return DeleteArtifactsBlocking(ctx, username, repoName, filenames) + }) + queue.Enqueue(task) + log.Printf("Enqueued deletion of %d artifacts", len(filenames)) +} - if upstream == "" || accessKey == "" || secretKey == "" || bucket == "" { - return fmt.Errorf("Object storage is not enabled for this server") - } +func DeleteArtifactsBlocking( + ctx context.Context, + username, + repoName string, + filenames []string, +) error { + conf := config.ForContext(ctx) + upstream, _ := conf.Get("objects", "s3-upstream") + accessKey, _ := conf.Get("objects", "s3-access-key") + secretKey, _ := conf.Get("objects", "s3-secret-key") + bucket, _ := conf.Get("git.sr.ht", "s3-bucket") + prefix, _ := conf.Get("git.sr.ht", "s3-prefix") - mc, err := minio.New(upstream, &minio.Options{ - Creds: credentials.NewStaticV4(accessKey, secretKey, ""), - Secure: true, - }) - if err != nil { - panic(err) - } + if upstream == "" || accessKey == "" || secretKey == "" || bucket == "" { + return fmt.Errorf("Object storage is not enabled for this server") + } - for _, filename := range filenames { - s3path := path.Join(prefix, "artifacts", "~"+username, repoName, filename) - if err := mc.RemoveObject(ctx, bucket, s3path, minio.RemoveObjectOptions{}); err != nil { - return err - } - } - return nil + mc, err := minio.New(upstream, &minio.Options{ + Creds: credentials.NewStaticV4(accessKey, secretKey, ""), + Secure: true, }) - queue.Enqueue(task) - log.Printf("Enqueued deletion of %d artifacts", len(filenames)) + if err != nil { + panic(err) + } + + for _, filename := range filenames { + s3path := path.Join(prefix, "artifacts", "~"+username, repoName, filename) + if err := mc.RemoveObject(ctx, bucket, s3path, minio.RemoveObjectOptions{}); err != nil { + return err + } + } + return nil } diff --git a/api/server.go b/api/server.go index 39de6d1..3304246 100644 --- a/api/server.go +++ b/api/server.go @@ -9,6 +9,7 @@ import ( work "git.sr.ht/~sircmpwn/dowork" "github.com/99designs/gqlgen/graphql" + "git.sr.ht/~sircmpwn/git.sr.ht/api/account" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/api" "git.sr.ht/~sircmpwn/git.sr.ht/api/graph/model" @@ -21,6 +22,7 @@ func main() { gqlConfig := api.Config{Resolvers: &graph.Resolver{}} gqlConfig.Directives.Private = server.Private + gqlConfig.Directives.Internal = server.Internal gqlConfig.Directives.Access = func(ctx context.Context, obj interface{}, next graphql.Resolver, scope model.AccessScope, kind model.AccessKind) (interface{}, error) { @@ -34,6 +36,7 @@ func main() { } reposQueue := work.NewQueue("repos") + accountQueue := work.NewQueue("account") webhookQueue := webhooks.NewQueue(schema) legacyWebhooks := webhooks.NewLegacyQueue() @@ -41,11 +44,16 @@ func main() { WithDefaultMiddleware(). WithMiddleware( loaders.Middleware, + account.Middleware(accountQueue), repos.Middleware(reposQueue), webhooks.Middleware(webhookQueue), webhooks.LegacyMiddleware(legacyWebhooks), ). WithSchema(schema, scopes). - WithQueues(reposQueue, webhookQueue.Queue, legacyWebhooks.Queue). + WithQueues( + accountQueue, + reposQueue, + webhookQueue.Queue, + legacyWebhooks.Queue). Run() } diff --git a/gitsrht-dispatch/main.go b/gitsrht-dispatch/main.go index 5f17b75..ddaa9ce 100644 --- a/gitsrht-dispatch/main.go +++ b/gitsrht-dispatch/main.go @@ -2,8 +2,8 @@ package main import ( "fmt" - "log" "io" + "log" "os" osuser "os/user" "strconv" @@ -14,9 +14,9 @@ import ( ) type Dispatcher struct { - cmd string - uid int - gid int + cmd string + uid int + gid int gids []int } @@ -30,7 +30,7 @@ func main() { logf, err := os.OpenFile("/var/log/gitsrht-dispatch", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { - log.Printf("Warning: unable to open log file: %v " + + log.Printf("Warning: unable to open log file: %v "+ "(using stderr instead)", err) logger = log.New(os.Stderr, "", log.LstdFlags) } else { diff --git a/gitsrht-keys/main.go b/gitsrht-keys/main.go index fa17183..64faf05 100644 --- a/gitsrht-keys/main.go +++ b/gitsrht-keys/main.go @@ -5,9 +5,9 @@ import ( "os" "path" + "git.sr.ht/~sircmpwn/scm.sr.ht/srht-keys" goredis "github.com/go-redis/redis/v8" "github.com/vaughan0/go-ini" - "git.sr.ht/~sircmpwn/scm.sr.ht/srht-keys" ) func main() { diff --git a/schema.sql b/schema.sql index ad6a58c..45a60b0 100644 --- a/schema.sql +++ b/schema.sql @@ -61,7 +61,7 @@ CREATE TABLE repository ( updated timestamp without time zone NOT NULL, name character varying(256) NOT NULL, description character varying(1024), - owner_id integer NOT NULL REFERENCES "user"(id), + owner_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, path character varying(1024), visibility visibility NOT NULL, readme character varying, @@ -77,7 +77,7 @@ CREATE TABLE access ( created timestamp without time zone NOT NULL, updated timestamp without time zone NOT NULL, repo_id integer NOT NULL REFERENCES repository(id) ON DELETE CASCADE, - user_id integer NOT NULL REFERENCES "user"(id), + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, mode character varying NOT NULL, CONSTRAINT uq_access_user_id_repo_id UNIQUE (user_id, repo_id) ); @@ -85,8 +85,8 @@ CREATE TABLE access ( CREATE TABLE artifacts ( id serial PRIMARY KEY, created timestamp without time zone NOT NULL, - user_id integer NOT NULL REFERENCES "user"(id), - repo_id integer NOT NULL REFERENCES repository(id), + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + repo_id integer NOT NULL REFERENCES repository(id) ON DELETE CASCADE, commit character varying NOT NULL, filename character varying NOT NULL, checksum character varying NOT NULL, @@ -98,7 +98,7 @@ CREATE TABLE redirect ( id serial PRIMARY KEY, created timestamp without time zone NOT NULL, name character varying(256) NOT NULL, - owner_id integer NOT NULL REFERENCES "user"(id), + owner_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, path character varying(1024), new_repo_id integer NOT NULL REFERENCES repository(id) ON DELETE CASCADE ); @@ -116,7 +116,7 @@ CREATE TABLE gql_user_wh_sub ( client_id uuid, expires timestamp without time zone, node_id character varying, - user_id integer NOT NULL REFERENCES "user"(id), + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, CONSTRAINT gql_user_wh_sub_auth_method_check CHECK ((auth_method = ANY (ARRAY['OAUTH2'::auth_method, 'INTERNAL'::auth_method]))), CONSTRAINT gql_user_wh_sub_check @@ -149,7 +149,7 @@ CREATE TABLE gql_user_wh_delivery ( -- Legacy SSH key table, to be fetched from meta.sr.ht instead (TODO: Remove) CREATE TABLE sshkey ( id serial PRIMARY KEY, - user_id integer NOT NULL REFERENCES "user"(id), + user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, meta_id integer NOT NULL, key character varying(4096) NOT NULL, fingerprint character varying(512) NOT NULL @@ -169,7 +169,7 @@ CREATE TABLE oauthtoken ( created timestamp without time zone NOT NULL, updated timestamp without time zone NOT NULL, expires timestamp without time zone NOT NULL, - user_id integer REFERENCES "user"(id), + user_id integer REFERENCES "user"(id) ON DELETE CASCADE, token_hash character varying(128) NOT NULL, token_partial character varying(8) NOT NULL, scopes character varying(512) NOT NULL @@ -181,8 +181,8 @@ CREATE TABLE user_webhook_subscription ( created timestamp without time zone NOT NULL, url character varying(2048) NOT NULL, events character varying NOT NULL, - user_id integer REFERENCES "user"(id), - token_id integer REFERENCES oauthtoken(id) + user_id integer REFERENCES "user"(id) ON DELETE CASCADE, + token_id integer REFERENCES oauthtoken(id) ON DELETE CASCADE ); CREATE TABLE user_webhook_delivery ( -- 2.38.4