Skip to content
Merged
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
15 changes: 15 additions & 0 deletions docs/coverage/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -9191,6 +9191,21 @@
}
]
},
{
"name": "TransparentDataEncryptions",
"doc": "TransparentDataEncryptions is an OPTIONAL Azure SQL capability, discovered by",
"operations": [
{
"name": "GetTransparentDataEncryption"
},
{
"name": "ListTransparentDataEncryption"
},
{
"name": "SetTransparentDataEncryption"
}
]
},
{
"name": "Users",
"doc": "Users is an OPTIONAL capability for managing database user accounts,",
Expand Down
11 changes: 11 additions & 0 deletions providers/azure/sql/databases.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ func (m *Mock) CreateDatabase(_ context.Context, cfg rdsdriver.DatabaseConfig) (
}
m.databases.Set(key, db)

// Azure SQL databases are encrypted at rest by default: a create
// materializes the transparentDataEncryption/current sub-resource as
// Enabled so a Get on it round-trips without a separate PUT.
m.tde.Set(key, rdsdriver.TransparentDataEncryption{
Server: cfg.Server,
Database: cfg.Name,
State: rdsdriver.TDEStateEnabled,
})

out := db

return &out, nil
Expand Down Expand Up @@ -114,5 +123,7 @@ func (m *Mock) DeleteDatabase(_ context.Context, server, name string) error {
return cerrors.Newf(cerrors.NotFound, "database %q not found on server %q", name, server)
}

m.tde.Delete(dbKey(server, name))

return nil
}
3 changes: 3 additions & 0 deletions providers/azure/sql/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type sqlSnapshot struct {
ManagedInstances json.RawMessage `json:"managedInstances,omitempty"`
ManagedDatabases json.RawMessage `json:"managedDatabases,omitempty"`
Databases json.RawMessage `json:"databases,omitempty"`
TDE json.RawMessage `json:"tde,omitempty"`
}

// Snapshot captures the mock's entire state as JSON. includeAssets is unused —
Expand Down Expand Up @@ -56,6 +57,7 @@ func (m *Mock) snapshotStores(snap *sqlSnapshot) error {
{&snap.ManagedInstances, m.managedInstances.Snapshot},
{&snap.ManagedDatabases, m.managedDatabases.Snapshot},
{&snap.Databases, m.databases.Snapshot},
{&snap.TDE, m.tde.Snapshot},
}

for _, d := range dumps {
Expand Down Expand Up @@ -93,6 +95,7 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error {
{snap.ManagedInstances, m.managedInstances.LoadSnapshot},
{snap.ManagedDatabases, m.managedDatabases.LoadSnapshot},
{snap.Databases, m.databases.LoadSnapshot},
{snap.TDE, m.tde.LoadSnapshot},
}

for _, l := range loads {
Expand Down
9 changes: 9 additions & 0 deletions providers/azure/sql/snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ func TestSnapshotRoundTripSQL(t *testing.T) {
t.Fatalf("create firewall rule: %v", err)
}

// A database auto-materializes a TDE record; both must survive the round-trip.
if _, err := src.CreateDatabase(ctx, rdsdriver.DatabaseConfig{Server: "srv1", Name: "appdb"}); err != nil {
t.Fatalf("create database: %v", err)
}

data, err := src.Snapshot(ctx, true)
requireNoError(t, err)

Expand All @@ -43,4 +48,8 @@ func TestSnapshotRoundTripSQL(t *testing.T) {
rules, err := dst.ListFirewallRules(ctx, "srv1")
requireNoError(t, err)
assertEqual(t, 1, len(rules))

tde, err := dst.GetTransparentDataEncryption(ctx, "srv1", "appdb")
requireNoError(t, err)
assertEqual(t, rdsdriver.TDEStateEnabled, tde.State)
}
4 changes: 4 additions & 0 deletions providers/azure/sql/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ type Mock struct {

// logical databases on a SQL server, key = "server/name"
databases *memstore.Store[rdsdriver.Database]
// transparent-data-encryption records, key = "server/database"
tde *memstore.Store[rdsdriver.TransparentDataEncryption]

opts *config.Options
monitoring mondriver.Monitoring
Expand All @@ -90,6 +92,7 @@ func New(opts *config.Options) *Mock {
failoverGroups: memstore.New[rdsdriver.FailoverGroup](),
aadAdmins: memstore.New[rdsdriver.AADAdmin](),
databases: memstore.New[rdsdriver.Database](),
tde: memstore.New[rdsdriver.TransparentDataEncryption](),
managedInstances: memstore.New[rdsdriver.ManagedInstance](),
managedDatabases: memstore.New[rdsdriver.ManagedDatabase](),
opts: opts,
Expand Down Expand Up @@ -580,6 +583,7 @@ func (m *Mock) deleteChildren(server string) {
prefix := server + "/"

deleteByPrefix(m.databases, prefix)
deleteByPrefix(m.tde, prefix)
deleteByPrefix(m.firewallRules, prefix)
deleteByPrefix(m.vnetRules, prefix)
deleteByPrefix(m.elasticPools, prefix)
Expand Down
71 changes: 71 additions & 0 deletions providers/azure/sql/tde.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package sql

import (
"context"

cerrors "github.com/stackshy/cloudemu/v2/errors"
rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver"
)

// SetTransparentDataEncryption sets the TDE state of a logical database,
// implementing the relationaldb TransparentDataEncryptions optional capability.
// It is the create-or-update for the transparentDataEncryption/current
// sub-resource: the database must exist (real Azure answers 404 otherwise), and
// an unset state defaults to Enabled to match Azure's encrypted-at-rest default.
func (m *Mock) SetTransparentDataEncryption(
_ context.Context, cfg rdsdriver.TransparentDataEncryptionConfig,
) (*rdsdriver.TransparentDataEncryption, error) {
m.mu.Lock()
defer m.mu.Unlock()

key := dbKey(cfg.Server, cfg.Database)
if _, ok := m.databases.Get(key); !ok {
return nil, cerrors.Newf(cerrors.NotFound, "database %q not found on server %q", cfg.Database, cfg.Server)
}

state := cfg.State
if state == "" {
state = rdsdriver.TDEStateEnabled
}

rec := rdsdriver.TransparentDataEncryption{Server: cfg.Server, Database: cfg.Database, State: state}
m.tde.Set(key, rec)

out := rec

return &out, nil
}

// GetTransparentDataEncryption returns a database's TDE state, or NotFound.
func (m *Mock) GetTransparentDataEncryption(
_ context.Context, server, database string,
) (*rdsdriver.TransparentDataEncryption, error) {
m.mu.RLock()
defer m.mu.RUnlock()

rec, ok := m.tde.Get(dbKey(server, database))
if !ok {
return nil, cerrors.Newf(cerrors.NotFound, "database %q not found on server %q", database, server)
}

out := rec

return &out, nil
}

// ListTransparentDataEncryption returns a database's TDE records. Azure models
// TDE as the single "current" sub-resource, so the list holds one entry (or is
// empty when the database does not exist).
func (m *Mock) ListTransparentDataEncryption(
_ context.Context, server, database string,
) ([]rdsdriver.TransparentDataEncryption, error) {
m.mu.RLock()
defer m.mu.RUnlock()

out := []rdsdriver.TransparentDataEncryption{}
if rec, ok := m.tde.Get(dbKey(server, database)); ok {
out = append(out, rec)
}

return out, nil
}
12 changes: 12 additions & 0 deletions server/azure/sql/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ const (
subFailoverGroups = "failoverGroups"
subAdministrators = "administrators"

// subTDE is the transparentDataEncryption sub-resource of a database; the
// trailing "/current" name segment is dropped by the 4-segment ParsePath, so
// it surfaces as rp.SubResourceAction under a database path.
subTDE = "transparentDataEncryption"

subMIStart = "start"
subMIStop = "stop"
subMIFailover = "failover"
Expand Down Expand Up @@ -168,6 +173,13 @@ func (h *Handler) serveDatabaseRoute(w http.ResponseWriter, r *http.Request, rp
return
}

// .../databases/{d}/transparentDataEncryption[/current]: a database
// sub-resource, not a database verb.
if rp.SubResourceAction == subTDE {
h.serveTDE(w, r, rp)
return
}

switch r.Method {
case http.MethodPut, http.MethodPatch:
h.putDatabase(w, r, rp, db)
Expand Down
125 changes: 125 additions & 0 deletions server/azure/sql/tde.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package sql

import (
"net/http"
"strings"

"github.com/stackshy/cloudemu/v2/server/wire/azurearm"
rdsdriver "github.com/stackshy/cloudemu/v2/services/relationaldb/driver"
)

// tdeName is the only transparentDataEncryption sub-resource name Azure defines
// ("current"), echoed on read responses.
const tdeName = "current"

func (h *Handler) transparentDataEncryption() (rdsdriver.TransparentDataEncryptions, bool) {
c, ok := h.db.(rdsdriver.TransparentDataEncryptions)
return c, ok
}

type armTDE struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
Properties *armTDECfg `json:"properties,omitempty"`
}

type armTDECfg struct {
State string `json:"state,omitempty"`
}

// serveTDE handles the database transparentDataEncryption/current sub-resource.
// Real Azure SQL TDE PUT is synchronous, so every verb returns 200 inline with
// no LRO. There is no Delete — TDE cannot be removed, only toggled.
func (h *Handler) serveTDE(w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath) {
tde, ok := h.transparentDataEncryption()
if !ok {
writeUnsupported(w, "transparentDataEncryption")
return
}

switch r.Method {
case http.MethodPut:
h.putTDE(w, r, rp, tde)
case http.MethodGet:
// A path ending at .../transparentDataEncryption (no "/current" name) is
// the list; one with the name is a single Get. The name segment is
// dropped by ParsePath, so distinguish on the raw path.
if tdeIsCollection(r.URL.Path) {
h.listTDE(w, r, rp, tde)
return
}

h.getTDE(w, r, rp, tde)
default:
writeMethodNotAllowed(w)
}
}

// tdeIsCollection reports whether urlPath addresses the transparentDataEncryption
// collection (ListByDatabase) rather than the single "current" sub-resource.
func tdeIsCollection(urlPath string) bool {
return strings.HasSuffix(strings.Trim(urlPath, "/"), "/"+subTDE)
}

func (*Handler) putTDE(
w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, tde rdsdriver.TransparentDataEncryptions,
) {
var body armTDE
if !azurearm.DecodeJSON(w, r, &body) {
return
}

cfg := rdsdriver.TransparentDataEncryptionConfig{Server: rp.ResourceName, Database: rp.SubResourceName}
if body.Properties != nil {
cfg.State = body.Properties.State
}

out, err := tde.SetTransparentDataEncryption(r.Context(), cfg)
if err != nil {
azurearm.WriteCErr(w, err)
return
}

azurearm.WriteJSON(w, http.StatusOK, toARMTDE(out, rp))
}

func (*Handler) getTDE(
w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, tde rdsdriver.TransparentDataEncryptions,
) {
out, err := tde.GetTransparentDataEncryption(r.Context(), rp.ResourceName, rp.SubResourceName)
if err != nil {
azurearm.WriteCErr(w, err)
return
}

azurearm.WriteJSON(w, http.StatusOK, toARMTDE(out, rp))
}

func (*Handler) listTDE(
w http.ResponseWriter, r *http.Request, rp *azurearm.ResourcePath, tde rdsdriver.TransparentDataEncryptions,
) {
items, err := tde.ListTransparentDataEncryption(r.Context(), rp.ResourceName, rp.SubResourceName)
if err != nil {
azurearm.WriteCErr(w, err)
return
}

out := make([]armTDE, 0, len(items))
for i := range items {
out = append(out, toARMTDE(&items[i], rp))
}

azurearm.WriteJSON(w, http.StatusOK, armList[armTDE]{Value: out})
}

func toARMTDE(t *rdsdriver.TransparentDataEncryption, rp *azurearm.ResourcePath) armTDE {
dbID := armDatabaseID(rp.Subscription, rp.ResourceGroup, t.Server, t.Database)

return armTDE{
ID: dbID + "/" + subTDE + "/" + tdeName,
Name: tdeName,
Type: providerName + "/" + resourceServers + "/" + subResourceDatabases + "/" + subTDE,
Properties: &armTDECfg{State: t.State},
}
}
Loading
Loading