Commit bd8d606
Eric Bower
·
2025-12-17 15:15:23 -0500 EST
parent 91a3cc1
feat: access logs This adds a new table to pico to track access logs. This helps pico admins understand what pubkeys are access their services and what their identity is in the case of certified public keys.
11 files changed,
+173,
-14
M
Makefile
+2,
-1
| ... | ... | @@ -142,10 +142,11 @@ migrate: | |
| 142 | 142 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250320_add_tunnel_id_to_tuns_event_logs_table.sql | |
| 143 | 143 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250410_add_index_analytics_visits_host_list.sql | |
| 144 | 144 | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250418_add_project_post_idx_analytics.sql | |
| 145 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251217_add_access_logs_table.sql | |
| 145 | 146 | .PHONY: migrate | |
| 146 | 147 | ||
| 147 | 148 | latest: | |
| 148 | - | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20250418_add_project_post_idx_analytics.sql | |
| 149 | + | $(DOCKER_CMD) exec -i $(DB_CONTAINER) psql -U $(PGUSER) -d $(PGDATABASE) < ./sql/migrations/20251217_add_access_logs_table.sql | |
| 149 | 150 | .PHONY: latest | |
| 150 | 151 | ||
| 151 | 152 | psql: |
+12,
-2
| ... | ... | @@ -262,14 +262,14 @@ func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc { | |
| 262 | 262 | return | |
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | - | pubkey, err := shared.PubkeyCertVerify(key, space) | |
| 265 | + | authed, err := shared.PubkeyCertVerify(key, space) | |
| 266 | 266 | if err != nil { | |
| 267 | 267 | log.Error("pubkey cert verify", "err", err) | |
| 268 | 268 | http.Error(w, err.Error(), http.StatusBadRequest) | |
| 269 | 269 | return | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | - | user, err := apiConfig.Dbpool.FindUserForKey(data.Username, pubkey) | |
| 272 | + | user, err := apiConfig.Dbpool.FindUserForKey(data.Username, authed.Pubkey) | |
| 273 | 273 | if err != nil { | |
| 274 | 274 | log.Error("find user for key", "err", err) | |
| 275 | 275 | w.WriteHeader(http.StatusUnauthorized) |
| ... | ... | @@ -282,6 +282,16 @@ func keyHandler(apiConfig *shared.ApiConfig) http.HandlerFunc { | |
| 282 | 282 | return | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | + | err = apiConfig.Dbpool.InsertAccessLog(&db.AccessLog{ | |
| 286 | + | UserID: user.ID, | |
| 287 | + | Service: space, | |
| 288 | + | Identity: authed.Identity, | |
| 289 | + | Pubkey: authed.OrigPubkey, | |
| 290 | + | }) | |
| 291 | + | if err != nil { | |
| 292 | + | log.Error("cannot insert access log", "err", err) | |
| 293 | + | } | |
| 294 | + | ||
| 285 | 295 | if !apiConfig.HasPrivilegedAccess(shared.GetApiToken(r)) { | |
| 286 | 296 | w.WriteHeader(http.StatusOK) | |
| 287 | 297 | return |
+1,
-0
| ... | ... | @@ -9,6 +9,7 @@ type PgsDB interface { | |
| 9 | 9 | FindUsers() ([]*db.User, error) | |
| 10 | 10 | ||
| 11 | 11 | FindFeature(userID string, name string) (*db.FeatureFlag, error) | |
| 12 | + | InsertAccessLog(*db.AccessLog) error | |
| 12 | 13 | ||
| 13 | 14 | InsertProject(userID, name, projectDir string) (string, error) | |
| 14 | 15 | UpdateProject(userID, name string) error |
+4,
-0
| ... | ... | @@ -191,3 +191,7 @@ func (me *MemoryDB) UpdateProjectAcl(userID, name string, acl db.ProjectAcl) err | |
| 191 | 191 | func (me *MemoryDB) RegisterAdmin(username, pubkey, pubkeyName string) error { | |
| 192 | 192 | return errNotImpl | |
| 193 | 193 | } | |
| 194 | + | ||
| 195 | + | func (me *MemoryDB) InsertAccessLog(*db.AccessLog) error { | |
| 196 | + | return errNotImpl | |
| 197 | + | } |
+11,
-0
| ... | ... | @@ -82,6 +82,17 @@ func (me *PgsPsqlDB) FindFeature(userID, name string) (*db.FeatureFlag, error) { | |
| 82 | 82 | return &ff, err | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func (me *PgsPsqlDB) InsertAccessLog(log *db.AccessLog) error { | |
| 86 | + | _, err := me.Db.Exec( | |
| 87 | + | `INSERT INTO access_logs (user_id, service, pubkey, identity) VALUES ($1, $2, $3, $4);`, | |
| 88 | + | log.UserID, | |
| 89 | + | log.Service, | |
| 90 | + | log.Pubkey, | |
| 91 | + | log.Identity, | |
| 92 | + | ) | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | ||
| 85 | 96 | func (me *PgsPsqlDB) InsertProject(userID, name, projectDir string) (string, error) { | |
| 86 | 97 | if !utils.IsValidSubdomain(name) { | |
| 87 | 98 | return "", fmt.Errorf("'%s' is not a valid project name, must match /^[a-z0-9-]+$/", name) |
+13,
-0
| ... | ... | @@ -205,6 +205,15 @@ type AnalyticsVisits struct { | |
| 205 | 205 | ContentType string `json:"content_type"` | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | + | type AccessLog struct { | |
| 209 | + | ID string `json:"id"` | |
| 210 | + | UserID string `json:"user_id"` | |
| 211 | + | Service string `json:"service"` | |
| 212 | + | Pubkey string `json:"pubkey"` | |
| 213 | + | Identity string `json:"identity"` | |
| 214 | + | CreatedAt *time.Time `json:"created_at"` | |
| 215 | + | } | |
| 216 | + | ||
| 208 | 217 | type Pager struct { | |
| 209 | 218 | Num int | |
| 210 | 219 | Page int |
| ... | ... | @@ -454,5 +463,9 @@ type DB interface { | |
| 454 | 463 | FindTunsEventLogs(userID string) ([]*TunsEventLog, error) | |
| 455 | 464 | FindTunsEventLogsByAddr(userID, addr string) ([]*TunsEventLog, error) | |
| 456 | 465 | ||
| 466 | + | InsertAccessLog(log *AccessLog) error | |
| 467 | + | FindAccessLogs(userID string, fromDate *time.Time) ([]*AccessLog, error) | |
| 468 | + | FindPubkeysInAccessLogs(userID string) ([]string, error) | |
| 469 | + | ||
| 457 | 470 | Close() error | |
| 458 | 471 | } |
+62,
-0
| ... | ... | @@ -1940,3 +1940,65 @@ func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) { | |
| 1940 | 1940 | stats.Pages = *pgs | |
| 1941 | 1941 | return &stats, err | |
| 1942 | 1942 | } | |
| 1943 | + | ||
| 1944 | + | func (me *PsqlDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) { | |
| 1945 | + | logs := []*db.AccessLog{} | |
| 1946 | + | rs, err := me.Db.Query( | |
| 1947 | + | `SELECT id, user_id, service, pubkey, identity, created_at FROM access_logs WHERE user_id=$1 AND created_at >= $2 ORDER BY created_at DESC`, userID, fromDate) | |
| 1948 | + | if err != nil { | |
| 1949 | + | return nil, err | |
| 1950 | + | } | |
| 1951 | + | ||
| 1952 | + | for rs.Next() { | |
| 1953 | + | log := db.AccessLog{} | |
| 1954 | + | err := rs.Scan( | |
| 1955 | + | &log.ID, &log.UserID, &log.Service, &log.Pubkey, &log.Identity, &log.CreatedAt, | |
| 1956 | + | ) | |
| 1957 | + | if err != nil { | |
| 1958 | + | return nil, err | |
| 1959 | + | } | |
| 1960 | + | logs = append(logs, &log) | |
| 1961 | + | } | |
| 1962 | + | ||
| 1963 | + | if rs.Err() != nil { | |
| 1964 | + | return nil, rs.Err() | |
| 1965 | + | } | |
| 1966 | + | ||
| 1967 | + | return logs, nil | |
| 1968 | + | } | |
| 1969 | + | ||
| 1970 | + | func (me *PsqlDB) FindPubkeysInAccessLogs(userID string) ([]string, error) { | |
| 1971 | + | pubkeys := []string{} | |
| 1972 | + | rs, err := me.Db.Query( | |
| 1973 | + | `SELECT DISTINCT(pubkey) FROM access_logs WHERE user_id=$1`, userID, | |
| 1974 | + | ) | |
| 1975 | + | if err != nil { | |
| 1976 | + | return nil, err | |
| 1977 | + | } | |
| 1978 | + | ||
| 1979 | + | for rs.Next() { | |
| 1980 | + | pubkey := "" | |
| 1981 | + | err := rs.Scan(&pubkey) | |
| 1982 | + | if err != nil { | |
| 1983 | + | return nil, err | |
| 1984 | + | } | |
| 1985 | + | pubkeys = append(pubkeys, pubkey) | |
| 1986 | + | } | |
| 1987 | + | ||
| 1988 | + | if rs.Err() != nil { | |
| 1989 | + | return nil, rs.Err() | |
| 1990 | + | } | |
| 1991 | + | ||
| 1992 | + | return pubkeys, nil | |
| 1993 | + | } | |
| 1994 | + | ||
| 1995 | + | func (me *PsqlDB) InsertAccessLog(log *db.AccessLog) error { | |
| 1996 | + | _, err := me.Db.Exec( | |
| 1997 | + | `INSERT INTO access_logs (user_id, service, pubkey, identity) VALUES ($1, $2, $3, $4);`, | |
| 1998 | + | log.UserID, | |
| 1999 | + | log.Service, | |
| 2000 | + | log.Pubkey, | |
| 2001 | + | log.Identity, | |
| 2002 | + | ) | |
| 2003 | + | return err | |
| 2004 | + | } |
+12,
-0
| ... | ... | @@ -288,3 +288,15 @@ func (me *StubDB) VisitUrlNotFound(opts *db.SummaryOpts) ([]*db.VisitUrl, error) | |
| 288 | 288 | func (me *StubDB) FindUsersWithPost(space string) ([]*db.User, error) { | |
| 289 | 289 | return nil, errNotImpl | |
| 290 | 290 | } | |
| 291 | + | ||
| 292 | + | func (me *StubDB) FindAccessLogs(userID string, fromDate *time.Time) ([]*db.AccessLog, error) { | |
| 293 | + | return nil, errNotImpl | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | func (me *StubDB) FindPubkeysInAccessLogs(userID string) ([]string, error) { | |
| 297 | + | return []string{}, errNotImpl | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func (me *StubDB) InsertAccessLog(log *db.AccessLog) error { | |
| 301 | + | return errNotImpl | |
| 302 | + | } |
+3,
-0
| ... | ... | @@ -51,11 +51,14 @@ func LogMiddleware(getLogger GetLoggerInterface, database FindUserInterface) SSH | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | if found { | |
| 54 | + | // identity provided by ssh-cert | |
| 55 | + | identity := s.Permissions().Extensions["identity"] | |
| 54 | 56 | if err == nil && user != nil { | |
| 55 | 57 | logger = logger.With( | |
| 56 | 58 | "user", user.Name, | |
| 57 | 59 | "userId", user.ID, | |
| 58 | 60 | "ip", s.RemoteAddr().String(), | |
| 61 | + | "identity", identity, | |
| 59 | 62 | ) | |
| 60 | 63 | ||
| 61 | 64 | SetUser(s, user) |
| ... | ... | @@ -23,6 +23,7 @@ type AuthFindUser interface { | |
| 23 | 23 | FindUserByPubkey(key string) (*db.User, error) | |
| 24 | 24 | FindUserByName(name string) (*db.User, error) | |
| 25 | 25 | FindFeature(userID, name string) (*db.FeatureFlag, error) | |
| 26 | + | InsertAccessLog(log *db.AccessLog) error | |
| 26 | 27 | } | |
| 27 | 28 | ||
| 28 | 29 | func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger, principal string) *SshAuthHandler { |
| ... | ... | @@ -33,11 +34,24 @@ func NewSshAuthHandler(dbh AuthFindUser, logger *slog.Logger, principal string) | |
| 33 | 34 | } | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 36 | - | func PubkeyCertVerify(key ssh.PublicKey, srcPrincipal string) (string, error) { | |
| 37 | + | type AuthedPubkey struct { | |
| 38 | + | OrigPubkey string | |
| 39 | + | Pubkey string | |
| 40 | + | Identity string | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func PubkeyCertVerify(key ssh.PublicKey, srcPrincipal string) (*AuthedPubkey, error) { | |
| 44 | + | origPubkey := utils.KeyForKeyText(key) | |
| 45 | + | authed := &AuthedPubkey{ | |
| 46 | + | OrigPubkey: origPubkey, | |
| 47 | + | Pubkey: origPubkey, | |
| 48 | + | Identity: "pubkey", | |
| 49 | + | } | |
| 50 | + | ||
| 37 | 51 | cert, ok := key.(*ssh.Certificate) | |
| 38 | 52 | if ok { | |
| 39 | 53 | if cert.CertType != ssh.UserCert { | |
| 40 | - | return "", fmt.Errorf("ssh-cert has type %d", cert.CertType) | |
| 54 | + | return nil, fmt.Errorf("ssh-cert has type %d", cert.CertType) | |
| 41 | 55 | } | |
| 42 | 56 | ||
| 43 | 57 | found := false |
| ... | ... | @@ -48,34 +62,36 @@ func PubkeyCertVerify(key ssh.PublicKey, srcPrincipal string) (string, error) { | |
| 48 | 62 | } | |
| 49 | 63 | } | |
| 50 | 64 | if !found { | |
| 51 | - | return "", fmt.Errorf("ssh-cert principals not valid") | |
| 65 | + | return nil, fmt.Errorf("ssh-cert principals not valid") | |
| 52 | 66 | } | |
| 53 | 67 | ||
| 54 | 68 | clock := time.Now | |
| 55 | 69 | unixNow := clock().Unix() | |
| 56 | 70 | if after := int64(cert.ValidAfter); after < 0 || unixNow < int64(cert.ValidAfter) { | |
| 57 | - | return "", fmt.Errorf("ssh-cert is not yet valid") | |
| 71 | + | return nil, fmt.Errorf("ssh-cert is not yet valid") | |
| 58 | 72 | } | |
| 59 | 73 | if before := int64(cert.ValidBefore); cert.ValidBefore != uint64(ssh.CertTimeInfinity) && (unixNow >= before || before < 0) { | |
| 60 | - | return "", fmt.Errorf("ssh-cert has expired") | |
| 74 | + | return nil, fmt.Errorf("ssh-cert has expired") | |
| 61 | 75 | } | |
| 62 | 76 | ||
| 63 | - | return utils.KeyForKeyText(cert.SignatureKey), nil | |
| 77 | + | authed.Pubkey = utils.KeyForKeyText(cert.SignatureKey) | |
| 78 | + | authed.Identity = cert.KeyId | |
| 79 | + | return authed, nil | |
| 64 | 80 | } | |
| 65 | 81 | ||
| 66 | - | return utils.KeyForKeyText(key), nil | |
| 82 | + | return authed, nil | |
| 67 | 83 | } | |
| 68 | 84 | ||
| 69 | 85 | func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { | |
| 70 | 86 | log := r.Logger | |
| 71 | 87 | var user *db.User | |
| 72 | 88 | var err error | |
| 73 | - | pubkey, err := PubkeyCertVerify(key, r.Principal) | |
| 89 | + | authed, err := PubkeyCertVerify(key, r.Principal) | |
| 74 | 90 | if err != nil { | |
| 75 | 91 | return nil, err | |
| 76 | 92 | } | |
| 77 | 93 | ||
| 78 | - | user, err = r.DB.FindUserByPubkey(pubkey) | |
| 94 | + | user, err = r.DB.FindUserByPubkey(authed.Pubkey) | |
| 79 | 95 | if err != nil { | |
| 80 | 96 | log.Error( | |
| 81 | 97 | "could not find user for key", |
| ... | ... | @@ -91,6 +107,16 @@ func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.Public | |
| 91 | 107 | return nil, fmt.Errorf("username is not set") | |
| 92 | 108 | } | |
| 93 | 109 | ||
| 110 | + | err = r.DB.InsertAccessLog(&db.AccessLog{ | |
| 111 | + | UserID: user.ID, | |
| 112 | + | Service: r.Principal, | |
| 113 | + | Identity: authed.Identity, | |
| 114 | + | Pubkey: authed.OrigPubkey, | |
| 115 | + | }) | |
| 116 | + | if err != nil { | |
| 117 | + | log.Error("cannot insert access log", "err", err) | |
| 118 | + | } | |
| 119 | + | ||
| 94 | 120 | // impersonation | |
| 95 | 121 | var impID string | |
| 96 | 122 | usr := conn.User() |
| ... | ... | @@ -108,8 +134,9 @@ func (r *SshAuthHandler) PubkeyAuthHandler(conn ssh.ConnMetadata, key ssh.Public | |
| 108 | 134 | ||
| 109 | 135 | perms := &ssh.Permissions{ | |
| 110 | 136 | Extensions: map[string]string{ | |
| 111 | - | "user_id": user.ID, | |
| 112 | - | "pubkey": pubkey, | |
| 137 | + | "user_id": user.ID, | |
| 138 | + | "pubkey": authed.Pubkey, | |
| 139 | + | "identity": authed.Identity, | |
| 113 | 140 | }, | |
| 114 | 141 | } | |
| 115 | 142 |
| ... | ... | @@ -0,0 +1,15 @@ | |
| 1 | + | CREATE TABLE IF NOT EXISTS access_logs ( | |
| 2 | + | id uuid NOT NULL DEFAULT uuid_generate_v4(), | |
| 3 | + | user_id uuid NOT NULL, | |
| 4 | + | service character varying(255) NOT NULL, | |
| 5 | + | pubkey text NOT NULL DEFAULT '', | |
| 6 | + | identity text NOT NULL DEFAULT '', | |
| 7 | + | data jsonb NOT NULL DEFAULT '{}'::jsonb, | |
| 8 | + | created_at timestamp without time zone NOT NULL DEFAULT NOW(), | |
| 9 | + | CONSTRAINT access_logs_pkey PRIMARY KEY (id), | |
| 10 | + | CONSTRAINT fk_access_logs_app_users | |
| 11 | + | FOREIGN KEY(user_id) | |
| 12 | + | REFERENCES app_users(id) | |
| 13 | + | ON DELETE CASCADE | |
| 14 | + | ON UPDATE CASCADE | |
| 15 | + | ); |