M api/graph/api/generated.go => api/graph/api/generated.go +7 -13
@@ 2334,14 2334,14 @@ func (ec *executionContext) _ACL_mode(ctx context.Context, field graphql.Collect
 		Object:     "ACL",
 		Field:      field,
 		Args:       nil,
-		IsMethod:   false,
+		IsMethod:   true,
 		IsResolver: false,
 	}
 
 	ctx = graphql.WithFieldContext(ctx, fc)
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
-		return obj.Mode, nil
+		return obj.Mode(), nil
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ 2350,9 2350,9 @@ func (ec *executionContext) _ACL_mode(ctx context.Context, field graphql.Collect
 	if resTmp == nil {
 		return graphql.Null
 	}
-	res := resTmp.(*model.AccessMode)
+	res := resTmp.(model.AccessMode)
 	fc.Result = res
-	return ec.marshalOAccessMode2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐAccessMode(ctx, field.Selections, res)
+	return ec.marshalOAccessMode2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐAccessMode(ctx, field.Selections, res)
 }
 
 func (ec *executionContext) _ACLCursor_results(ctx context.Context, field graphql.CollectedField, obj *model.ACLCursor) (ret graphql.Marshaler) {
@@ 10318,19 10318,13 @@ func (ec *executionContext) marshalOACL2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsr
 	return ec._ACL(ctx, sel, v)
 }
 
-func (ec *executionContext) unmarshalOAccessMode2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐAccessMode(ctx context.Context, v interface{}) (*model.AccessMode, error) {
-	if v == nil {
-		return nil, nil
-	}
-	var res = new(model.AccessMode)
+func (ec *executionContext) unmarshalOAccessMode2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐAccessMode(ctx context.Context, v interface{}) (model.AccessMode, error) {
+	var res model.AccessMode
 	err := res.UnmarshalGQL(v)
 	return res, graphql.ErrorOnPath(ctx, err)
 }
 
-func (ec *executionContext) marshalOAccessMode2ᚖgitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐAccessMode(ctx context.Context, sel ast.SelectionSet, v *model.AccessMode) graphql.Marshaler {
-	if v == nil {
-		return graphql.Null
-	}
+func (ec *executionContext) marshalOAccessMode2gitᚗsrᚗhtᚋאsircmpwnᚋgitᚗsrᚗhtᚋapiᚋgraphᚋmodelᚐAccessMode(ctx context.Context, sel ast.SelectionSet, v model.AccessMode) graphql.Marshaler {
 	return v
 }
 
 
M api/graph/model/acl.go => api/graph/model/acl.go +16 -6
@@ 3,7 3,9 @@ package model
 import (
 	"context"
 	"database/sql"
+	"fmt"
 	"strconv"
+	"strings"
 	"time"
 
 	sq "github.com/Masterminds/squirrel"
@@ 14,17 16,25 @@ import (
 
 // TODO: Drop updated column from database
 type ACL struct {
-	ID         int         `json:"id"`
-	Created    time.Time   `json:"created"`
-	Mode       *AccessMode `json:"mode"`
+	ID         int        `json:"id"`
+	Created    time.Time  `json:"created"`
 
-	RepoID int
-	UserID int
+	RawAccessMode string
+	RepoID        int
+	UserID        int
 
 	alias  string
 	fields *database.ModelFields
 }
 
+func (acl *ACL) Mode() AccessMode {
+	mode := AccessMode(strings.ToUpper(acl.RawAccessMode))
+	if !mode.IsValid() {
+		panic(fmt.Errorf("Invalid access mode '%s'", acl.RawAccessMode)) // Invariant
+	}
+	return mode
+}
+
 func (acl *ACL) As(alias string) *ACL {
 	acl.alias = alias
 	return acl
@@ 46,7 56,7 @@ func (acl *ACL) Fields() *database.ModelFields {
 		Fields: []*database.FieldMap{
 			{ "id", "id", &acl.ID },
 			{ "created", "created", &acl.Created },
-			{ "mode", "mode", &acl.Mode },
+			{ "mode", "mode", &acl.RawAccessMode },
 
 			// Always fetch:
 			{ "id", "", &acl.ID },
 
M api/graph/schema.resolvers.go => api/graph/schema.resolvers.go +41 -1
@@ 318,7 318,47 @@ func (r *mutationResolver) DeleteRepository(ctx context.Context, id int) (*model
 }
 
 func (r *mutationResolver) UpdateACL(ctx context.Context, repoID int, mode model.AccessMode, entity string) (*model.ACL, error) {
-	panic(fmt.Errorf("updateACL: not implemented"))
+	if entity[0] != '~' {
+		return nil, fmt.Errorf("Unknown entity %s", entity)
+	}
+	entity = entity[1:]
+
+	if entity == auth.ForContext(ctx).Username {
+		return nil, fmt.Errorf("Cannot edit your own access modes")
+	}
+
+	var acl model.ACL
+	if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error {
+		row := tx.QueryRowContext(ctx, `
+			WITH grantee AS (
+				SELECT u.id uid, repo.id rid
+				FROM "user" u, repository repo
+				WHERE u.username = $3 AND repo.id = $1 AND repo.owner_id = $2
+			)
+			INSERT INTO access (created, updated, mode, user_id, repo_id)
+			SELECT
+				NOW() at time zone 'utc', NOW() at time zone 'utc',
+				$4, grantee.uid, grantee.rid
+			FROM grantee
+			ON CONFLICT ON CONSTRAINT uq_access_user_id_repo_id
+			DO UPDATE SET mode = $4, updated = NOW() at time zone 'utc'
+			RETURNING id, created, mode, repo_id, user_id;`,
+			repoID, auth.ForContext(ctx).UserID,
+			entity, strings.ToLower(string(mode)))
+		if err := row.Scan(&acl.ID, &acl.Created, &acl.RawAccessMode,
+			&acl.RepoID, &acl.UserID); err != nil {
+			if err == sql.ErrNoRows {
+				// TODO: Fetch user details from meta.sr.ht
+				return fmt.Errorf("No such repository or user found")
+			}
+			return err
+		}
+		return nil
+	}); err != nil {
+		return nil, err
+	}
+
+	return &acl, nil
 }
 
 func (r *mutationResolver) DeleteACL(ctx context.Context, repoID int, entity string) (*model.ACL, error) {
 
A gitsrht/alembic/versions/85ba19185fec_add_unique_constraint_to_acls.py => gitsrht/alembic/versions/85ba19185fec_add_unique_constraint_to_acls.py +23 -0
@@ 0,0 1,23 @@
+"""Add unique constraint to ACLs
+
+Revision ID: 85ba19185fec
+Revises: c167cf8a1271
+Create Date: 2020-11-27 10:28:15.303415
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '85ba19185fec'
+down_revision = 'c167cf8a1271'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    op.create_unique_constraint('uq_access_user_id_repo_id', 'access',
+            ['user_id', 'repo_id'])
+
+
+def downgrade():
+    op.drop_constraint('uq_access_user_id_repo_id', 'access')