359 lines
11 KiB
Go
359 lines
11 KiB
Go
// Package model defines the data types persisted as the tool's state.
|
|
//
|
|
// Hosts (each offering one or more TCP services) form a shared catalog,
|
|
// defined once; groups reference individual services of that catalog (not
|
|
// whole hosts) by name, so access can be scoped per-service rather than
|
|
// per-host; and users are assigned to one or more groups.
|
|
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// nameRe restricts identifiers that end up as ssh_config tokens or
|
|
// authorized_keys comments: no spaces, no shell/config metacharacters.
|
|
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
|
|
// ValidName reports whether s is safe to use as a group/host/service name:
|
|
// letters, digits, dot, underscore, hyphen, no spaces. Names end up as
|
|
// ssh_config comments/tokens and authorized_keys metadata, where a stray
|
|
// space or "#" would corrupt the generated file. Names also never contain
|
|
// "/", which ServiceRef keys (see below) rely on as a safe separator.
|
|
func ValidName(s string) bool {
|
|
return nameRe.MatchString(s)
|
|
}
|
|
|
|
// Service is one TCP port reachable on a Host — e.g. the host's sshd on
|
|
// port 22, or an unrelated application on port 7888. A single Host commonly
|
|
// exposes several of these.
|
|
type Service struct {
|
|
Name string `yaml:"name"` // short identifier, e.g. "ssh" or "web-api"
|
|
Port int `yaml:"port"` // remote TCP port on the host
|
|
Description string `yaml:"description,omitempty"`
|
|
}
|
|
|
|
// Host is a catalog entry: one jump-target reachable via port forwarding
|
|
// through the bastion, defined once and referenced by any number of groups
|
|
// (see Group.Services) — hosts/services shared across many groups only
|
|
// need to be entered a single time.
|
|
type Host struct {
|
|
Name string `yaml:"name"` // short identifier, e.g. "db-prod-1"
|
|
Address string `yaml:"address"` // hostname or IP as seen from the jump host
|
|
Description string `yaml:"description,omitempty"`
|
|
Services []Service `yaml:"services"`
|
|
}
|
|
|
|
// ServiceRef points at one specific service of one specific catalog host.
|
|
// Groups grant access per-service, not per-host: a group can include just
|
|
// "db1/ssh" without also granting "db1/web-admin".
|
|
type ServiceRef struct {
|
|
Host string `yaml:"host"`
|
|
Service string `yaml:"service"`
|
|
}
|
|
|
|
// Group is a named access policy referencing a subset of catalog services
|
|
// (by host+service name). Users are assigned to one or more groups; their
|
|
// effective allowed forwards are the union of all services referenced by
|
|
// all their groups.
|
|
type Group struct {
|
|
Name string `yaml:"name"`
|
|
Description string `yaml:"description,omitempty"`
|
|
Services []ServiceRef `yaml:"services"`
|
|
}
|
|
|
|
// UserStatus is the lifecycle state of a managed account.
|
|
type UserStatus string
|
|
|
|
const (
|
|
StatusActive UserStatus = "active"
|
|
StatusLocked UserStatus = "locked"
|
|
)
|
|
|
|
// User is one managed Linux/SSH account on the jump host.
|
|
type User struct {
|
|
Username string `yaml:"username"`
|
|
Groups []string `yaml:"groups"`
|
|
Status UserStatus `yaml:"status"`
|
|
Fingerprint string `yaml:"fingerprint"` // SHA256 fingerprint of the current authorized public key
|
|
PublicKey string `yaml:"public_key,omitempty"` // full "ssh-ed25519 AAAA... comment" line; safe to store/back up (public), lets us rewrite authorized_keys (e.g. after a group's referenced services change) without re-issuing the keypair
|
|
// ForwardsHash is HashForwards(AllowedForwards(u)) as of the last time
|
|
// this user was handed a config (full export or config-only update). If
|
|
// it no longer matches the live computation, their local ssh config is
|
|
// stale — see IsConfigStale.
|
|
ForwardsHash string `yaml:"forwards_hash,omitempty"`
|
|
Comment string `yaml:"comment,omitempty"`
|
|
CreatedAt time.Time `yaml:"created_at"`
|
|
LockedAt *time.Time `yaml:"locked_at,omitempty"`
|
|
}
|
|
|
|
// State is the full persisted configuration of the tool.
|
|
type State struct {
|
|
Hosts []Host `yaml:"hosts"` // shared catalog
|
|
Groups []Group `yaml:"groups"`
|
|
Users []User `yaml:"users"`
|
|
}
|
|
|
|
// FindHost returns a pointer to the catalog host with the given name, or nil.
|
|
func (s *State) FindHost(name string) *Host {
|
|
for i := range s.Hosts {
|
|
if s.Hosts[i].Name == name {
|
|
return &s.Hosts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindGroup returns a pointer to the group with the given name, or nil.
|
|
func (s *State) FindGroup(name string) *Group {
|
|
for i := range s.Groups {
|
|
if s.Groups[i].Name == name {
|
|
return &s.Groups[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindUser returns a pointer to the user with the given username, or nil.
|
|
func (s *State) FindUser(username string) *User {
|
|
for i := range s.Users {
|
|
if s.Users[i].Username == username {
|
|
return &s.Users[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Forward is one flattened (host, service) pair a user is allowed to reach.
|
|
// This is the actual unit both server-side enforcement (authorized_keys
|
|
// "permitopen=") and the client config (one "LocalForward" line per
|
|
// Forward) are built from.
|
|
type Forward struct {
|
|
HostName string
|
|
Address string
|
|
Port int
|
|
ServiceName string
|
|
Description string
|
|
}
|
|
|
|
// AllowedForwards returns the de-duplicated union of (host, service) pairs
|
|
// across every ServiceRef of every group the user belongs to, in stable
|
|
// encounter order. Two services that resolve to the same address:port
|
|
// (even from different groups) collapse to a single Forward.
|
|
func (s *State) AllowedForwards(u *User) []Forward {
|
|
seen := map[string]bool{}
|
|
var out []Forward
|
|
for _, gname := range u.Groups {
|
|
g := s.FindGroup(gname)
|
|
if g == nil {
|
|
continue
|
|
}
|
|
for _, ref := range g.Services {
|
|
h := s.FindHost(ref.Host)
|
|
if h == nil {
|
|
continue
|
|
}
|
|
var svc *Service
|
|
for i := range h.Services {
|
|
if h.Services[i].Name == ref.Service {
|
|
svc = &h.Services[i]
|
|
break
|
|
}
|
|
}
|
|
if svc == nil {
|
|
continue
|
|
}
|
|
key := fmt.Sprintf("%s:%d", h.Address, svc.Port)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, Forward{
|
|
HostName: h.Name,
|
|
Address: h.Address,
|
|
Port: svc.Port,
|
|
ServiceName: svc.Name,
|
|
Description: svc.Description,
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GroupNamesReferencingHost returns every group that references at least
|
|
// one service of hostName.
|
|
func (s *State) GroupNamesReferencingHost(hostName string) []string {
|
|
var out []string
|
|
for _, g := range s.Groups {
|
|
for _, ref := range g.Services {
|
|
if ref.Host == hostName {
|
|
out = append(out, g.Name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GroupNamesReferencingService returns every group that references this
|
|
// exact (host, service) pair.
|
|
func (s *State) GroupNamesReferencingService(hostName, serviceName string) []string {
|
|
var out []string
|
|
for _, g := range s.Groups {
|
|
for _, ref := range g.Services {
|
|
if ref.Host == hostName && ref.Service == serviceName {
|
|
out = append(out, g.Name)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UsernamesInGroup returns every user currently a member of groupName.
|
|
func (s *State) UsernamesInGroup(groupName string) []string {
|
|
var out []string
|
|
for _, u := range s.Users {
|
|
for _, g := range u.Groups {
|
|
if g == groupName {
|
|
out = append(out, u.Username)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// usernamesInAnyGroup returns the deduplicated union of UsernamesInGroup
|
|
// across the given group names.
|
|
func (s *State) usernamesInAnyGroup(groupNames []string) []string {
|
|
seen := map[string]bool{}
|
|
var out []string
|
|
for _, gname := range groupNames {
|
|
for _, uname := range s.UsernamesInGroup(gname) {
|
|
if !seen[uname] {
|
|
seen[uname] = true
|
|
out = append(out, uname)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// UsernamesUsingHost returns every user reachable transitively through any
|
|
// group that references any service of hostName (deduplicated).
|
|
func (s *State) UsernamesUsingHost(hostName string) []string {
|
|
return s.usernamesInAnyGroup(s.GroupNamesReferencingHost(hostName))
|
|
}
|
|
|
|
// UsernamesUsingService returns every user reachable transitively through
|
|
// any group that references this exact (host, service) pair (deduplicated).
|
|
func (s *State) UsernamesUsingService(hostName, serviceName string) []string {
|
|
return s.usernamesInAnyGroup(s.GroupNamesReferencingService(hostName, serviceName))
|
|
}
|
|
|
|
// RenameGroup renames a group and updates every user's membership reference
|
|
// so nobody silently falls out of a group just because it was renamed.
|
|
func (s *State) RenameGroup(oldName, newName string) {
|
|
if g := s.FindGroup(oldName); g != nil {
|
|
g.Name = newName
|
|
}
|
|
for i := range s.Users {
|
|
for j, gn := range s.Users[i].Groups {
|
|
if gn == oldName {
|
|
s.Users[i].Groups[j] = newName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DeleteGroup removes a group and drops the membership reference from every
|
|
// user who had it. Caller is responsible for resyncing authorized_keys for
|
|
// any affected user (see UsernamesInGroup, called before this).
|
|
func (s *State) DeleteGroup(name string) {
|
|
for i := range s.Groups {
|
|
if s.Groups[i].Name == name {
|
|
s.Groups = append(s.Groups[:i], s.Groups[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
for i := range s.Users {
|
|
var kept []string
|
|
for _, gn := range s.Users[i].Groups {
|
|
if gn != name {
|
|
kept = append(kept, gn)
|
|
}
|
|
}
|
|
s.Users[i].Groups = kept
|
|
}
|
|
}
|
|
|
|
// RenameHost renames a catalog host and updates every group's ServiceRefs
|
|
// pointing at it, so a rename doesn't silently drop the host's services out
|
|
// of groups using them.
|
|
func (s *State) RenameHost(oldName, newName string) {
|
|
if h := s.FindHost(oldName); h != nil {
|
|
h.Name = newName
|
|
}
|
|
for i := range s.Groups {
|
|
for j := range s.Groups[i].Services {
|
|
if s.Groups[i].Services[j].Host == oldName {
|
|
s.Groups[i].Services[j].Host = newName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// DeleteHost removes a host from the catalog and drops every group's
|
|
// ServiceRefs that pointed at any of its services. Caller is responsible
|
|
// for resyncing authorized_keys for any affected user (see
|
|
// UsernamesUsingHost, called before this).
|
|
func (s *State) DeleteHost(name string) {
|
|
for i := range s.Hosts {
|
|
if s.Hosts[i].Name == name {
|
|
s.Hosts = append(s.Hosts[:i], s.Hosts[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
for i := range s.Groups {
|
|
var kept []ServiceRef
|
|
for _, ref := range s.Groups[i].Services {
|
|
if ref.Host != name {
|
|
kept = append(kept, ref)
|
|
}
|
|
}
|
|
s.Groups[i].Services = kept
|
|
}
|
|
}
|
|
|
|
// RenameServiceRefs updates every group's ServiceRef pointing at
|
|
// (hostName, oldService) to (hostName, newService), so renaming a service
|
|
// doesn't silently drop it out of groups using it.
|
|
func (s *State) RenameServiceRefs(hostName, oldService, newService string) {
|
|
for i := range s.Groups {
|
|
for j := range s.Groups[i].Services {
|
|
ref := &s.Groups[i].Services[j]
|
|
if ref.Host == hostName && ref.Service == oldService {
|
|
ref.Service = newService
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// RemoveServiceRefs drops every group's ServiceRef pointing at
|
|
// (hostName, serviceName). Caller is responsible for resyncing
|
|
// authorized_keys for any affected user (see UsernamesUsingService, called
|
|
// before this).
|
|
func (s *State) RemoveServiceRefs(hostName, serviceName string) {
|
|
for i := range s.Groups {
|
|
var kept []ServiceRef
|
|
for _, ref := range s.Groups[i].Services {
|
|
if !(ref.Host == hostName && ref.Service == serviceName) {
|
|
kept = append(kept, ref)
|
|
}
|
|
}
|
|
s.Groups[i].Services = kept
|
|
}
|
|
}
|