Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/content/permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Permission System

Here is the design of the permission system to be implemented for use by the mist compute platform. The idea is very similar to unix groups.

Each user will be tagged with workspaces (groups) they belong to and data storage sections that they may have read and or write access to. Beware that once this system is deployed it will need to be easy to migrate and switch between, so whatever implementation you make, make sure it easy to switch to and away from.

Demands the system needs to satisfy:

1. multiple people can be in multiple workspaces (for now workspace = group).
2. every job is attributed to some workspace or project, so it can be counted towards that groups resource use.
3. new workspaces need to be created easily.
4. system needs to be possible such as to allow future changes.
5. we can assume that users within a workspace trust each other and there are no significant malicious actors among the users.

Common groups formats (that will be inferfaced by the UI to appear that way, low level implementation shouldn't concern itself with this layer of abstraction) "personal," "team," "project," etc. 

Jobs will be tagged by the workspace they were launched from, and counted towards that workspace's resource use.

## Implementation

The system is most naturally represented as a bipartite graph of 3 sets: users linked to workspaces; and workspaces linked to data and jobs.

Data and job running permissions (mostly quotas, queues, priority) are attached to groups.

Present plans are to do everything using a very naive SQL database
2 changes: 2 additions & 0 deletions src/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ func NewKubernetesApp(executor *KubernetesExecutor, log *slog.Logger) *App {
a.teams = newTeamService(a)
mux.HandleFunc("/teams", a.teamEndpoint)
mux.HandleFunc("/teams/", a.teamEndpoint)
mux.HandleFunc("/workspaces", a.teamEndpoint)
mux.HandleFunc("/workspaces/", a.teamEndpoint)
a.httpServer.Handler = a.auth.middleware(a.teams.middleware(mux))
} else {
a.httpServer.Handler = a.auth.middleware(mux)
Expand Down
15 changes: 15 additions & 0 deletions src/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ func (s *AuthService) session(r *http.Request) (*Member, error) {
WHERE s.token = ? AND s.expiresAt > ? AND COALESCE(u.banned, 0) = 0
`, token, now).Scan(&m.ID, &m.Name, &m.Email, &m.Role)
if err == nil {
m.IsAdmin = isAdmin(&m)
return &m, nil
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, err
Expand Down Expand Up @@ -337,6 +338,7 @@ func (s *AuthService) LookupMember(ctx context.Context, id string) (*Member, err
if err != nil {
return nil, errors.New("account unavailable")
}
m.IsAdmin = isAdmin(&m)
return &m, nil
}

Expand Down Expand Up @@ -877,3 +879,16 @@ func (s *AuthService) middleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), memberKey{}, member)))
})
}

func (s *AuthService) Session(r *http.Request) (*Member, error) {
return s.session(r)
}

func (s *AuthService) ActiveMembers(ctx context.Context) (map[string]AccountStatus, error) {
return s.activeMembers(ctx)
}

func (s *AuthService) Middleware(next http.Handler) http.Handler {
return s.middleware(next)
}

14 changes: 13 additions & 1 deletion src/jobs_http.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,19 @@ func (a *App) kubernetesJobs(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusCreated, map[string]interface{}{"job_id": status.ID, "job": status})
case http.MethodGet:
jobs, err := a.requestExecutor(r).list(r.Context())
var jobs []KubernetesJobStatus
var err error
if r.URL.Query().Get("cluster") == "true" || r.URL.Query().Get("all") == "true" {
var currentMember *Member
if access, _ := r.Context().Value(teamContextKey{}).(*TeamAccess); access != nil {
currentMember = access.Member
} else {
currentMember, _ = r.Context().Value(memberKey{}).(*Member)
}
jobs, err = a.executor.listCluster(r.Context(), currentMember)
} else {
jobs, err = a.requestExecutor(r).list(r.Context())
}
if err != nil {
executorError(w, err)
return
Expand Down
30 changes: 29 additions & 1 deletion src/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,30 @@ func (e *KubernetesExecutor) list(ctx context.Context) ([]KubernetesJobStatus, e
return result, nil
}

func (e *KubernetesExecutor) listCluster(ctx context.Context, currentMember *Member) ([]KubernetesJobStatus, error) {
jobs, err := e.client.BatchV1().Jobs("").List(ctx, metav1.ListOptions{LabelSelector: managedLabel + "=mist"})
if err != nil {
return nil, err
}
result := make([]KubernetesJobStatus, 0, len(jobs.Items))
data, err := e.loadJobState(ctx)
if err != nil {
return nil, err
}
for i := range jobs.Items {
status, err := e.statusWithData(ctx, &jobs.Items[i], data)
if err != nil {
continue
}
if currentMember != nil {
status.CanCancel = ptr(isAdmin(currentMember) || status.CreatorID == currentMember.ID)
}
result = append(result, *status)
}
sort.Slice(result, func(i, j int) bool { return result[i].Created.After(result[j].Created) })
return result, nil
}

func (e *KubernetesExecutor) logs(ctx context.Context, id string) (string, error) {
job, err := e.managedJob(ctx, id)
if err != nil {
Expand Down Expand Up @@ -758,7 +782,11 @@ func (e *KubernetesExecutor) cancel(ctx context.Context, id string) (*Kubernetes
if err != nil {
return nil, err
}
if e.team != nil && !isAdmin(e.team.Member) && job.Annotations["mist.io/creator-id"] != e.team.Member.ID {
if e.team != nil {
if !isAdmin(e.team.Member) && job.Annotations["mist.io/creator-id"] != e.team.Member.ID {
return nil, &permissionError{errors.New("Only the job owner or an administrator can cancel this job")}
}
} else if job.Labels["mist.io/owner"] != "" && job.Labels["mist.io/owner"] != e.owner {
return nil, &permissionError{errors.New("Only the job owner or an administrator can cancel this job")}
}
if job.Annotations[cancelAnnotation] != "" {
Expand Down
70 changes: 66 additions & 4 deletions src/member_auth.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package main

import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"net/http"
"os"
Expand All @@ -17,16 +19,76 @@ func hashID(id string) string {
}

type Member struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
IsAdmin bool `json:"is_admin"`
}

type AccountStatus struct {
Role string `json:"role"`
}

// Authenticator provides an interface for user authentication, member lookups,
// and session middleware. This allows plugging in the incoming core auth system,
// the current Better Auth / SQLite implementation, or dummy mock implementations.
type Authenticator interface {
http.Handler
Close() error
Session(r *http.Request) (*Member, error)
LookupMember(ctx context.Context, id string) (*Member, error)
ActiveMembers(ctx context.Context) (map[string]AccountStatus, error)
Middleware(next http.Handler) http.Handler
}

// Ensure AuthService implements Authenticator.
var _ Authenticator = (*AuthService)(nil)

// DummyAuthenticator is a stub implementation of Authenticator for testing or headless runs.
type DummyAuthenticator struct {
Members map[string]*Member
}

func NewDummyAuthenticator() *DummyAuthenticator {
return &DummyAuthenticator{
Members: make(map[string]*Member),
}
}

func (d *DummyAuthenticator) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

func (d *DummyAuthenticator) Close() error {
return nil
}

func (d *DummyAuthenticator) Session(r *http.Request) (*Member, error) {
return nil, nil
}

func (d *DummyAuthenticator) LookupMember(ctx context.Context, id string) (*Member, error) {
if m, ok := d.Members[id]; ok {
return m, nil
}
return nil, errors.New("account unavailable")
}

func (d *DummyAuthenticator) ActiveMembers(ctx context.Context) (map[string]AccountStatus, error) {
res := make(map[string]AccountStatus)
for id, m := range d.Members {
res[id] = AccountStatus{Role: m.Role}
}
return res, nil
}

func (d *DummyAuthenticator) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}

type AuthGateway = AuthService

func newAuthGateway(raw string) (*AuthGateway, error) {
Expand Down
4 changes: 2 additions & 2 deletions src/shared_storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func (s *SharedStorage) availableUploadLimit(team bool) int64 {
return 0
}
reserve := int64(1024 * 1024 * 1024)
return max(0, min(s.uploadLimit, int64(stats.Bavail)*stats.Bsize-reserve))
return max(0, min(s.uploadLimit, int64(stats.Bavail)*int64(stats.Bsize)-reserve))
}
func (s *SharedStorage) dataset(id, owner string) (*Dataset, error) {
if !datasetIDPattern.MatchString(id) {
Expand Down Expand Up @@ -138,7 +138,7 @@ func (s *SharedStorage) upload(r *http.Request, owner string) (*Dataset, error)
return nil, err
}
reserve := int64(1024 * 1024 * 1024)
remaining := int64(stats.Bavail)*stats.Bsize - reserve
remaining := int64(stats.Bavail)*int64(stats.Bsize) - reserve
if remaining <= 0 {
return nil, fmt.Errorf("dataset storage has insufficient free space: %w", syscall.ENOSPC)
}
Expand Down
Loading
Loading